Merge branch 'main' into feat-web-native-consistency
The branch was cut against 9225ec2 and main has since gained mid-call device switching, which lands in the same place: both rewrite the room's controls block. Resolved by keeping the branch's structure — the call workspace, the stage, the Focus/Grid segmented control, the chat sidebar toggle, icons on every control — and putting the Devices button and its panel inside it. The panel takes a row of its own under the controls that opened it (`grid-column: 1 / -1`, and row 3 once the sidebar exists). The alternative was the sidebar column, where it would argue with chat over 20rem for three selects and a sentence. Two small things the resolution needed. The Devices button wanted an icon like every other control in the row, so icons.go gains Lucide's sliders-horizontal. And the branch's `.meet-pickers > div` makes every picker a ~12rem flex item, which is right for the three selects and wrong for the studio switch — its sentence of explanation sets in a ribbon at that width — so `.meet-pickers > .meet-raw-row` takes its line back, by specificity rather than by order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 files changed,
+2196
−57
AGENTS.md+24 −5Makefile+2 −0PRODUCT.md+269 −0README.md+175 −0docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md+157 −0docs/superpowers/reviews/2026-09-14-slice-6-record.md+209 −0docs/superpowers/specs/2026-09-07-meet-design.md+128 −40internal/meet/icons/icons.go+6 −5internal/meet/static/devices.js+90 −2internal/meet/static/meet.css+43 −0internal/meet/static/room.js+577 −3internal/meet/templates/join.html+8 −0internal/meet/templates/room.html+27 −0internal/meettest/browser_test.go+1 −0internal/meettest/holding_test.go+40 −0internal/meettest/room_test.go+47 −0test/browser/audio.test.mjs+130 −0test/browser/call.mjs+107 −0test/browser/devices.test.mjs+116 −0test/browser/room-lane.test.mjs+40 −2
CI failed — run details
diff --git a/AGENTS.md b/AGENTS.md| index 3bc2096..a163561 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -50,11 +50,13 @@ mechanically. |
| a rename is indistinguishable from a drop plus an add. |
| - Money is integer cents. A float never touches a value a person will |
| be held to. |
| -- Screens work with JavaScript disabled, with one exception: browser |
| +- Screens work with JavaScript disabled, with two exceptions: browser |
| media participation (the holding screen's camera preview and join |
| - button, and the call itself in the room) needs script; both pages |
| - render their title, standing, floor notice and navigation without it. |
| - Destructive actions get their own confirm-page URL. |
| + button, and the call itself in the room), and, once a call exists, |
| + the room's chat panel and its admission queue's Admit controls; both |
| + pages still render their title, standing, floor notice and |
| + navigation without script. Destructive actions get their own |
| + confirm-page URL. |
| - The gate is `make ci` (vet + gofmt + tests) — the same definition |
| CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the |
| stack is cgo-free by design. |
| @@ -81,9 +83,26 @@ the golden deployment target, not a requirement: `./app -addr :8080` |
| serves anywhere. `GET /healthz` and `GET /api/version` are answered |
| by the framework, outside app middleware. |
| +## Deploying |
| + |
| +`make release VERSION=<sha>` builds and stamps the artifact; `carlos |
| +deploy --app meet --account oficina --channel edge --version <sha> |
| +--target linux-arm64 releases/meet-linux-arm64` ships and promotes it, |
| +waiting until `/api/version` serves it — but only once `carlos apps |
| +build-probe --app meet --account oficina /api/version` has been run |
| +for the app (set on the flagship app since 2026-09-09); a new app or |
| +account needs that first, or the wait watches `X-Carlos-Version` |
| +instead, which can report the version adopted before the process has |
| +actually changed. A migration applied under an earlier filename fails |
| +at boot; otherwise migrations never re-run. Every deploy, and every |
| +relay config change, restarts the instance and drops live calls — no |
| +drain exists. The wait can time out before |
| +hand-over — check `/api/version` before retrying. See the README's |
| +Deploying section for the canary recipe. |
| + |
| ## UX conventions (seeded from profile: considered) |
| -Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable, with the one exception named above: browser media participation, on the holding screen and in the room. |
| +Accessible by construction, progressively enhanced, and never dependent on JavaScript to be usable, with the exceptions named above: browser media participation, on the holding screen and in the room, and, once a call exists, the room's chat panel and admission controls. |
| These are rastrillo's own conventions, and the profile is named for what it does rather than after anyone else's work. For wider reading on interface quality, impeccable.style, the WAI-ARIA Authoring Practices and Inclusive Components are all worth your time — none of them is endorsed by, or authoritative for, this framework. |
diff --git a/Makefile b/Makefile| index 70bb573..d542b79 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -149,6 +149,8 @@ browser: |
| node --test test/browser/room-share.test.mjs |
| node --test test/browser/room-lane.test.mjs |
| node --test test/browser/media.test.mjs |
| + node --test test/browser/audio.test.mjs |
| + node --test test/browser/devices.test.mjs |
| npm install --no-save playwright@1.62.1 |
| npx playwright install chromium |
| go test -tags browser -count=1 -timeout 20m -run TestCallInARealBrowser -v ./internal/meettest/ |
diff --git a/PRODUCT.md b/PRODUCT.md| new file mode 100644 |
| index 0000000..871ef8e |
| --- /dev/null |
| +++ b/PRODUCT.md |
| @@ -0,0 +1,269 @@ |
| +# Product |
| + |
| +<!-- impeccable:product-schema 1 --> |
| + |
| +> **How this file was written.** Written unattended, on the same |
| +> instruction that produced `oficina/docs`' `PRODUCT.md`: every fact |
| +> below comes from repository evidence, not an interview — |
| +> `docs/superpowers/specs/2026-09-07-meet-design.md` (revision 9, plus |
| +> the 2026-09-10 and 2026-09-12 amendments), `AGENTS.md`, the slice |
| +> records under `docs/superpowers/reviews/`, `native/README.md`, and the |
| +> shipped code in `internal/meet/` on branch `slice/6-deploy`, main |
| +> `9225ec2`. Facts are cited to a spec section or a file. A reading |
| +> rather than a citation is marked **[inferred — confirm]** and should |
| +> be corrected, not trusted. A bare "ruling N" is the Meet spec's §1; a |
| +> doctrine ruling is always written "suite ruling N". |
| + |
| +## Platform |
| + |
| +Web, plus native Mac and iPhone/iPad clients over the same server |
| +contracts (spec §8.4's 2026-09-14 amendment, `native/README.md`). |
| + |
| +## Users |
| + |
| +**Primary: a member of one team**, making and joining that team's video |
| +calls. The instance boundary is the team — one instance per team |
| +(preamble; suite ruling 1, 5, §0.2), the "lone shape" until a suite home |
| +exists (§7.1). **Secondary: an outside guest**, admitted with a real |
| +identity, never as an anonymous URL holder: every participant signs in, |
| +and a meeting link gives an outsider a **guest grant**, never a way in |
| +without a name (ruling 1; suite ruling 15); a guest waits until an |
| +admitted member lets them in (ruling 9, §3.3). |
| + |
| +Access is a magic link gated by `MEET_ALLOWED_ADDRESSES` |
| +(`internal/meet/config.go`), with optional Home sign-in when an operator |
| +sets all four `MEET_HOME_*` settings (§7.1.1) — Home only *links* an |
| +existing member after independent local sign-in; it never creates a |
| +person or grants membership. Unlike Docs' §8.1, the spec states no |
| +passkey ruling of its own. **[inferred — confirm]** the team is assumed |
| +small enough that an address allowlist is appropriate; the spec states |
| +no target size. |
| + |
| +## Product Purpose |
| + |
| +Meet is **video calling for one Oficina team**: a member makes a |
| +meeting, sends one link, and the people who follow it set up camera and |
| +microphone, wait to be let in if not on the team, and talk (preamble). |
| +"The instance carries the pages, the sign-in, the signalling and the |
| +in-call chat, and never a frame of media" (§0.1) — falsifiable: publish |
| +the SQLite file, its WAL, and every log line, and the worst anyone |
| +learns is the shape of the team's meetings and who minted what, never a |
| +title, chat text, or signalling payload, enforced by a WAL-grep test and |
| +a captured-logger test, not merely asserted (§0.1). |
| + |
| +## Positioning |
| + |
| +1. **The claim is made per process, not once** (§0.1): Meet's own |
| + process never touches a media packet; the platform TURN relay |
| + forwards encrypted DTLS-SRTP it cannot read; a future platform SFU |
| + decrypts to forward and gets its own spec revision when it ships |
| + (§5.7). |
| +2. **No relay of its own, by ruling** (suite ruling 6, §5.2): present |
| + `CARLOS_RELAY` mints ICE credentials per call; absent means host |
| + candidates only, stated before the join button (§5.5). |
| +3. **Two authorities, not one, for a room.** *Standing* (§2.3, computed |
| + per request, never stored) decides who may **enter**; a |
| + *participation* (§2.4, hub state) decides who may **continue** — an |
| + admitted guest keeps talking past its grant's expiry while a member |
| + who loses standing is dropped at once (ruling 14). |
| + |
| +## Operating Context |
| + |
| +- **One Go binary per instance**, `CGO_ENABLED=0`, deployed on CARLOS as |
| + a **unit route** (§7.3). Native Apple bindings under `native/` are the |
| + one place cgo is used, isolated in `native/go.mod` (`AGENTS.md`). |
| +- **Routes today** (`internal/meet/app.go`): `GET /`, `POST /meetings`; |
| + `GET /j/{token}` (outside the session group); the holding screen and |
| + room; signalling (`stream`, `signal`, `ice`); `POST /m/{sqid}/share` |
| + (2026-09-12); five destructive confirmation pairs (remove, takeover, |
| + replace invitation, end meeting, revoke agent); Settings and agent |
| + tokens; sign-in including optional Home routes; native handoff |
| + endpoints; a bearer-only `/api/v1/*` group (`api.go`) outside CSRF and |
| + the session group. `GET /healthz`/`GET /api/version` answer from |
| + rastrillo itself, outside app middleware. |
| +- **Scriptless, with browser media participation as the exception, and |
| + two script-only parts inside a live room.** Reaching a camera and |
| + another browser needs script; so, once a call exists, do the chat |
| + panel (`partials/chat.html` renders it `hidden` until script un-hides |
| + it) and the admission queue's Admit controls (`room.js`). Every other |
| + screen — the list, creating a meeting, a link shown once, every |
| + confirm page, sign-in, `/welcome`, the agent token page, the Share |
| + page and the Home account-link screens (§7.1.1), and the |
| + server-rendered parts of the holding screen and room — works with |
| + scripts off (§9; `AGENTS.md`'s UX line carries the media exception; |
| + full list below). |
| +- **Nothing stored beyond a meeting's bones.** Rooms, peers, admission, |
| + chat and presence live in an in-process hub and die with the process |
| + (§2.2); a restart drops every live call (§4.5). Stored: `persons`, |
| + `meetings` (sealed title, `internal/meet/seal/`), `meeting_links` |
| + (hash-indexed plus a sealed copy for sharing since 2026-09-12), |
| + `pending_joins` (a keyed address fingerprint, never plaintext), |
| + `guest_grants`, `agents` (a member's minted principal; the token |
| + itself is never stored, only its SHA-256) (models.go; §2.1). |
| + |
| +## Capabilities and Constraints |
| + |
| +**Shipped:** meeting creation and listing; a link shown once, replaced |
| +or shared via `POST /m/{sqid}/share`; sign-in with the pending-join |
| +exception admitting an outsider only while holding a live link (ruling |
| +12, §3.2); optional Home account-linking (§7.1.1); the holding screen |
| +and a P2P mesh room capped at four with perfect negotiation, resume, and |
| +a replay ring (§4); relay-minted ICE credentials renewed before deadline |
| +(§5.2, §5.3); ephemeral chat and screen share (§6, §5.4); a bearer-token |
| +drive surface for agents re-checking operator membership on every |
| +request (§8, §15.17); native Mac and iPhone/iPad apps sharing the Go |
| +protocol core (`native/README.md`). |
| + |
| +**Deliberately not built yet** (§11, as amended): see "What is |
| +deliberately not here" below for the full, cited list. |
| + |
| +**Binding technical constraints:** |
| + |
| +- **Media never touches Meet's own process** (§0.1); no handler accepts |
| + a media packet. **`scope.Owned`, no bound request body**: every |
| + user-owned query goes through it, reads and writes alike; a row that |
| + isn't yours 404s, never 403s (`AGENTS.md`). |
| +- **Standing is computed, never cached** — every request, every |
| + 15-second hub tick (§2.3, §2.4). A shipped migration is never edited. |
| + The agent API's rate limit keys on the resolved agent, not the client |
| + address, deliberately (§15.17; see below). |
| +- **`CGO_ENABLED=0 make ci` is the server's cgo-free gate.** Two |
| + things run under `CGO_ENABLED=1` and never ship: `make race`, for |
| + any change to `internal/meet/hub` or the stream, and `make |
| + native-test`'s own `meetcore` race sub-step. The native Mac and |
| + iPhone/iPad apps are the one place real cgo does ship — `MeetBridge` |
| + is a binary target `MeetKit` links (`native/apple/Package.swift`, |
| + `native/scripts/build-mac.sh`) — while every server release stays |
| + `CGO_ENABLED=0` (`AGENTS.md`). **`make browser`** needs UDP, which |
| + the Claude Code bash sandbox blocks (`AGENTS.md`). |
| + |
| +## Brand Commitments |
| + |
| +- **Name:** Meet, `amadan.net/oficina/meet`, first served at |
| + `meet.oficina.works` (preamble; ruling 2 chose `oficina/meet` over |
| + `oficina/conferencia`). Built beside Docs, Calendar, Slides and the |
| + suite doctrine, consuming a decision made there rather than restating |
| + it (preamble, §0.2). |
| +- **The theme is Estilo's**, adopted at whatever Estilo holds when each |
| + slice lands. As recorded on 2026-09-14 (`git ls-remote --tags` |
| + against `oficina/estilo`, empty at that check), no tag exists; |
| + consumed at a pseudo-version, already adopted by the Home integration |
| + on main. **[inferred — confirm]** the exact commit at any later date. |
| +- **The chat panel is Meet's contribution to Estilo** — "one |
| + self-contained template partial and one script file so a second app |
| + can adopt it," the suite's first chat, built to be taken (§6). |
| +- **Voice: [inferred — confirm].** Copy review was waived for this |
| + documentation round (steering on `oficina/suite` discussion 5), so |
| + shipped strings were not checked against a review record. |
| + |
| +## Evidence on Hand |
| + |
| +- **Spec:** revision 9 plus the 2026-09-10 and 2026-09-12 amendments, |
| + 2100+ lines, six rounds of Codex review (§14). **Plans:** |
| + `docs/superpowers/plans/`, one programme (slices 2–6) plus a plan per |
| + slice. **Slice records:** `docs/superpowers/reviews/`, one per slice |
| + 2–5 (room, relay, chat/screen-share, drive surface), each an index |
| + into the spec's own §15 rulings for that slice. |
| +- **Native:** `native/README.md`, `native/DESIGN.md`, |
| + `native/VERIFICATION.md` — build, gates, stated acceptance limits (no |
| + real-device pass claimed for camera permissions, audio-device |
| + switching, sleep/wake, or Home passkeys). |
| +- **What does not exist and must not be invented:** customers, |
| + testimonials, benchmarks, pricing, usage numbers, launch date, press, |
| + or a production promotion from this slice — edge |
| + (`meet.oficina.works`) is promoted by hand by Paul, separately from |
| + documentation (slice 6 plan, "What slice 6 was"). The acknowledgements |
| + page and the Estilo theme pin are the other two slice-6 items with a |
| + disposition rather than a delivery; spec §15.18 is the record for all |
| + three. |
| + |
| +## Product Principles |
| + |
| +1. **State the degradation rather than let it be discovered.** The |
| + no-relay floor says calls usually connect only on the same network |
| + (§5.5); §0.3 states what the privacy claim excludes (signalling in |
| + flight, a replay ring of up to 32 messages for 30 seconds, a link as |
| + a bearer); the mail-budget residue is written down, not claimed away |
| + (§3.2). |
| +2. **A verification that cannot distinguish "no" from "I can't see it" |
| + is not a verification.** The §0.1 claim is enforced by grepping raw |
| + SQLite bytes and the WAL and capturing the logger during a real call; |
| + `make browser` asserts a received track by `ontrack` and frame count, |
| + never a label (§13). |
| +3. **Two authorities are named because one would be wrong in some |
| + direction** (§2.3–§2.4, ruling 14): a single authority either drops |
| + an admitted guest at the grant's two-hour mark or lets an expired |
| + grant re-enter. |
| +4. **A contract is written for the next client, not the current one.** |
| + Signalling, `/ice` and the meeting API are contracts a native client |
| + could speak (ruling 15, §8.4); the native apps are that ruling paying |
| + off. |
| +5. **Actors are `person:<id>` or `agent:<id>`, never fabricated** |
| + (§7.2, doctrine ruling 9). |
| + |
| +## Accessibility & Inclusion |
| + |
| +`AGENTS.md`'s vendored UI conventions apply: labelled fields with one |
| +error envelope, destructive actions on their own confirm-page URL and |
| +never a modal (`ui/confirm-form` — §3.1, §3.5, §4.2), state shown with a |
| +text label and not colour alone, and empty states that say what the |
| +screen is for. |
| + |
| +**The scriptless floor is the accessibility constraint this app adds |
| +beyond that shared list.** Reaching a camera and another browser needs |
| +script, and so, once a call exists, do the room's chat panel and its |
| +admission-queue Admit controls; nothing else does. With scripts off |
| +the holding screen's join button becomes one paragraph saying a call |
| +needs JavaScript and everything else on the page works without it |
| +(§3.3); the room does the same (§3.4). |
| + |
| +No target locale count or WCAG conformance level is stated in the Meet |
| +spec or `AGENTS.md`. **[inferred — confirm]**: Docs states "WCAG 2.2 AA, |
| +no exemptions" for itself; nothing here says the same target binds Meet, |
| +and this file does not assume it does. |
| + |
| +## What is deliberately not here |
| + |
| +- **Scriptless media, chat and admission** — the exceptions to the |
| + scriptless floor: reaching a camera and another browser needs script, |
| + and so, once a call exists, do the room's chat panel and its |
| + admission-queue Admit controls; every other screen, including the |
| + server-rendered parts of the holding screen and the room, works with |
| + scripts off (spec §9; `AGENTS.md`'s UX-conventions line carries the |
| + media exception in the same words). |
| +- **No STUN or TURN server of Meet's own.** ICE servers come from the |
| + platform relay, present or not at all — never a server Meet names |
| + itself (suite ruling 6, §5.2, §5.5). |
| +- **No SFU, and the room is capped at four admitted peers** — not a |
| + claimed product limit, the mesh's own (ruling 5), lifted only when a |
| + platform SFU that decrypts to forward ships, in its own spec revision, |
| + not a quiet cap change (§5.7, §0.3, §11 "rooms above four"). |
| +- **No client-address limiter on the drive surface.** The agent API's |
| + per-agent rate limit (one request a second sustained, burst 60) keys |
| + on the resolved agent, not the client address: behind the platform's |
| + relay socket every request can share one address, and an |
| + address-keyed limiter would let one flood take out every real agent |
| + at once (§15.17). |
| +- **No passkeys ruling of its own.** Unlike Docs §8.1, the spec states |
| + no instance-local-passkey decision. **[inferred — confirm]**. |
| +- **Chat is never stored.** A `chat` message fans out to admitted peers |
| + and renders client-side; not replayed to a peer who joins or resumes |
| + later, gone with the tab (ruling 8, §6). |
| +- **No recurring meetings, Calendar integration, recording, attendance |
| + records, reactions, dial-in, captions, multi-host signalling, |
| + push-to-talk, or virtual backgrounds, and no way to deny a knock** — a |
| + guest who isn't admitted simply waits or leaves (§11). |
| +- **No native client sign-in method of its own** — a native client |
| + authenticates through the same Home/local sign-in the browser uses, |
| + handed off through a five-minute, one-use authorization; no separate |
| + bearer or token scheme for native apps (§8.4's 2026-09-14 amendment, |
| + `native/README.md`). |
| + **No attendance claim, ever**: §0.3 says Meet does not record who was |
| + in a call or for how long; an earlier revision implied one and the |
| + spec dropped the implication rather than building the model. |
| + |
| +--- |
| + |
| +*See also:* the design spec, `README.md`, `AGENTS.md`, and the slice |
| +records under `docs/superpowers/reviews/`. |
| + |
diff --git a/README.md b/README.md| new file mode 100644 |
| index 0000000..53753fb |
| --- /dev/null |
| +++ b/README.md |
| @@ -0,0 +1,175 @@ |
| +# Meet |
| + |
| +Meet is video calling for one team. A member makes a meeting, now or |
| +for later, and sends one link; anyone who follows it sets up their |
| +camera and microphone on a holding screen, waits to be let in if they |
| +are not on the team, and talks in a room of up to four, with chat |
| +beside the call and a screen to share. An agent can also create, |
| +list, relink and end meetings over an API. Every screen works with |
| +JavaScript disabled except browser media participation — the camera |
| +preview and join button on the holding screen, and the call itself in |
| +the room — and, once a call exists, the room's two other script-only |
| +parts: the chat panel and the live admission queue with its Admit |
| +controls. Both pages still render their title, start time, standing, |
| +floor notice, the paragraph that says what needs script, and a |
| +member's navigation with scripts off (spec §9). |
| + |
| +ICE servers come from the CARLOS platform relay, delivered as |
| +`CARLOS_RELAY` and minted fresh for each call at `GET /m/{sqid}/ice`; |
| +Meet names no STUN or TURN server of its own (suite ruling 6). With no |
| +relay configured, calls are direct only: two browsers connect when ICE |
| +can find a working path straight between them, which the holding |
| +screen states plainly above the join button: "This team's Meet has no |
| +relay yet. Calls usually connect only between people on the same |
| +network." Enabling or changing the relay restarts the instance. When |
| +the relay is in the path it forwards DTLS-SRTP packets encrypted |
| +between the browsers, so it carries bytes it cannot read; Meet's own |
| +process never receives a media packet at all. With the relay on, a |
| +call that still fails says "could not connect, even through the |
| +relay" and offers retry; it does not diagnose the network. One known |
| +cause is a network that allows only TCP 443, because the relay's TLS |
| +listener is deferred by the platform — a possibility, not a verdict |
| +(spec §0.1, §5.2, §5.5, §5.6, §7.3, §15.13). |
| + |
| +## Sign-in |
| + |
| +Sign-in is a magic link to an address on the team's allowlist |
| +(`MEET_ALLOWED_ADDRESSES`). Following a meeting link gives an outsider |
| +a guest grant at sign-in — never a way into a room without a name — |
| +and the allowlist otherwise gates sign-in exactly as it does for a |
| +member (spec §3, §7.1). An operator can also enable Home sign-in with |
| +all four of `MEET_HOME_ISSUER`, `MEET_HOME_CONSUMER_ID`, |
| +`MEET_HOME_KEYS_BASE64` and `MEET_HOME_DEK_BASE64` — never a partial |
| +set — which links an existing member after a fresh, independent local |
| +sign-in and explicit confirmation; it never creates a person or grants |
| +membership on its own (spec §7.1.1). Because a member's address can be |
| +rate-limited on purpose and gets a different answer from an unknown |
| +one, whether a given address is on the team is not a secret the |
| +sign-in form can keep; that residue is stated rather than claimed away |
| +(spec §3.2). |
| + |
| +## The room |
| + |
| +The room is a peer-to-peer mesh of up to four admitted participants, |
| +negotiated with the W3C perfect-negotiation pattern over an SSE stream |
| +and a POST signal per peer. A guest waits in a queue until an admitted |
| +member lets them in; removing a participant and ending a meeting each |
| +go through their own confirm page — there is no in-room control that |
| +skips one (spec §3.5). A member can retrieve the current invitation |
| +again from the holding screen or the room with Share (`POST |
| +/m/{sqid}/share`). On a meeting whose only link predates the |
| +2026-09-12 sharing update, the first Share mints a new recoverable |
| +invitation alongside the old, still-valid one rather than opening it; |
| +every Share after that retrieves the same recoverable one. Replacing a |
| +link revokes every live one. Chat is fanned out live to admitted peers |
| +and never stored (spec §6). Screen share adds a second tile per sharer |
| +(spec §5.4). The room lives in one process's memory, so every deploy |
| +drops every live call (spec §15.11). |
| + |
| +## Agents |
| + |
| +A member mints an agent's bearer token from Settings; it is shown once |
| +and the row after it keeps only its hash. The token works on |
| +`/api/v1` only — never a cookie there, and never a bearer on an HTML |
| +route — for five operations: list meetings, create one, replace its |
| +link, end it, and read one meeting's detail. It cannot join a call |
| +(spec §8). An agent's reach is re-derived from its operator's current |
| +standing on every request, so an operator who leaves the team takes |
| +their agent's reach with them at once, with no separate revocation |
| +step. Requests are limited per agent to one a second sustained, with a |
| +burst of 60 (spec §8, §15.17). |
| + |
| +## Native apps |
| + |
| +The SwiftUI Mac and iPhone apps use this same server and a shared Go |
| +core (`meetcore/`, `protocol/`); there are no embedded webviews. Build |
| +the Mac app with `make native-mac`. See `native/README.md` and |
| +`native/ios/README.md` for setup, native sign-in and the native gates. |
| + |
| +## Building and gates |
| + |
| +`CGO_ENABLED=0 make ci` (vet, gofmt, tests, migration check) is the |
| +one gate a runner and a push share; `ci` itself does not set the |
| +variable, so the runner and every push must. Three things ship or run |
| +under `CGO_ENABLED=1`, each isolated from the server: `make race` |
| +builds a server test binary, never a release, for any change to |
| +`internal/meet/hub` or the signalling stream; `make native-test`'s own |
| +`meetcore` race sub-step does the same for the shared Go core; and the |
| +native Mac and iPhone/iPad bindings under `native/` bridge Go to |
| +Objective-C with real cgo, isolated in `native/go.mod` and built with |
| +`make native-mac`. `make browser` drives a real call across two real |
| +Chromium contexts, 31 scenarios in about nine minutes; it needs the |
| +Claude Code bash sandbox off, because the sandbox blocks UDP and every |
| +call then fails with `frames` stuck at 0. `make release VERSION=<sha>` |
| +stamps that version into `/api/version`, so a deploy can be checked |
| +against what it is meant to be running. |
| + |
| +## Deploying |
| + |
| +Meet deploys as a CARLOS unit route. Build the artifact first — `make |
| +release VERSION=<sha>` produces `releases/meet-linux-arm64`, stamped |
| +with the version `/api/version` will report (`strings -a |
| +releases/meet-linux-arm64 | grep -cx <sha>` confirms the stamp) — then |
| +deploy it: `carlos deploy --app meet --account oficina --channel edge |
| +--version <sha> --target linux-arm64 releases/meet-linux-arm64`, |
| +which waits until `/api/version` reports it — but only once a build |
| +probe is configured for the app: `carlos apps build-probe --app meet |
| +--account oficina /api/version`, set on the flagship app since |
| +2026-09-09. A new app or account needs that command run once before |
| +the wait means anything; without it `carlos deploy` watches |
| +`X-Carlos-Version` instead, which can report the new version adopted |
| +before the running process has actually changed. `--account` is a |
| +global flag; `carlos ship` uploads only, while `carlos deploy` is ship, |
| +promote and wait (its own help text). Building for your own |
| +architecture and shipping that uploads fine and fails to exec on the |
| +instance. |
| + |
| +These are the settings a deploy needs (`config.go`, `standing.go`). |
| +Five refuse startup when empty outside `-env development`: |
| +`MEET_ORIGIN` (the external origin, scheme included); |
| +`MEET_INSTANCE_KEY`; `MEET_DEK` (base64 of exactly 32 decoded bytes, |
| +not 32 base64 characters); `MEET_ALLOWED_ADDRESSES` (comma-separated, |
| +non-empty — this is the team); and mail, `MEET_SMTP_*` or the |
| +platform's `CARLOS_SMTP_*`. Two are optional, each with its own |
| +default rather than a refusal: `MEET_ZONE` (an IANA zone, defaulting |
| +to UTC when unset) and `CARLOS_RELAY` (empty means no relay, §5.5 — |
| +only a *malformed* value refuses startup), delivered by `carlos relay |
| +enable --app meet` rather than set by hand. Secrets — |
| +`MEET_INSTANCE_KEY`, `MEET_DEK` and `MEET_SMTP_PASS` — go through |
| +`carlos secrets`; the rest go through `carlos env`. Mail reads |
| +`MEET_SMTP_*` with `CARLOS_SMTP_*` as a per-variable fallback; the |
| +platform supplies a sender too |
| +(`CARLOS_SMTP_FROM`), which `MEET_SMTP_FROM` can optionally override, |
| +so `carlos email enable` alone is enough to send mail. `carlos relay |
| +enable --app meet` turns on the platform relay. Every deploy, and |
| +every relay change, restarts the instance and drops every live call — |
| +no drain exists, so deploy outside meeting hours. Migrations apply |
| +once each at boot (`msg=migrate` in the log) and are never re-run; a |
| +database that already applied a migration file under an earlier name |
| +fails at boot rather than silently diverging. The deploy wait can time |
| +out before the hand-over while the instance then serves the new |
| +version a minute later — check `/api/version` before retrying. |
| + |
| +Canary a build on its own host: `carlos canary --app meet --name |
| +slice2` composed `slice2.meet.bds.oncarlos.com`, and the console |
| +refused it ("invalid host, an instance host is one DNS label followed |
| +by .bds.oncarlos.com") — as recorded on 2026-09-10 in platform |
| +discussion #124, open as of that record. The recipe actually used: |
| +`carlos instances create --app meet --account oficina --host |
| +slice2-meet.bds.oncarlos.com --channel canary/slice2 --environment |
| +slice2`. The `slice2` environment bundle must already exist, created |
| +separately, carrying `MEET_ORIGIN` for the canary host; without it |
| +sign-in refuses on the canary while GETs look healthy. Then `carlos |
| +deploy --account oficina --app meet --channel canary/slice2 --host |
| +slice2-meet.bds.oncarlos.com --version <sha> |
| +releases/meet-linux-arm64`. |
| + |
| +## Where things are written down |
| + |
| +The design lives in `docs/superpowers/specs/2026-09-07-meet-design.md`, |
| +the slice plans in `docs/superpowers/plans/`, and each slice's review |
| +in `docs/superpowers/reviews/`. `PRODUCT.md` covers what Meet is for |
| +and what it deliberately leaves out; `AGENTS.md` covers the |
| +conventions the code is held to. Spec §15.18 is the disposition of |
| +record for what slice 6 delivered, deferred (the acknowledgements |
| +page) and declined (the Estilo theme pin, the production promotion). |
diff --git a/docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md b/docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md| new file mode 100644 |
| index 0000000..dbbce1a |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md |
| @@ -0,0 +1,157 @@ |
| +# Meet Slice 6: Deploy Docs Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Close the Meet programme (spec §12 item 6) with the documents a deployed app owes: README, PRODUCT.md, the *Consumed suite rulings* commit for suite Appendix A.6, AGENTS.md's final notes, and the slice record, each true of main as it is on 2026-09-14. |
| + |
| +**Architecture:** Documentation only. Nothing under `internal/`, `native/`, `meetcore/` or `protocol/` changes. Every sentence about behaviour is checked against the code on main `9225ec2` before it is written, and cites the spec section or file it comes from. Items of the programme's slice 6 that other sessions have already delivered or are delivering are recorded as such, not redone. |
| + |
| +**Tech Stack:** Markdown; the spec at `docs/superpowers/specs/2026-09-07-meet-design.md` (revision 9 plus the 2026-09-10 and 2026-09-12 amendments on main); the suite sharing doctrine at `oficina/suite` `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md` revision 8, main `1ac4323`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-07-meet-design.md` §7.3, §9, §10, §11, §12 item 6, §0.2. Programme: `docs/superpowers/plans/2026-09-09-meet-slices-2-6-programme.md` (slice 6 line and §5). |
| + |
| +## Global Constraints |
| + |
| +- Branch `slice/6-deploy` from main `9225ec2`; docs only; `make ci` stays green (it is unaffected by Markdown, run it once at the end). |
| +- No user-facing app string changes. Copy review is waived for this round by Paul's steering on `oficina/suite` discussion 5 ("copy review is explicitly waived for this entire massive round"); README and PRODUCT.md prose is catalogued in the record, not reviewed. |
| +- Every claim about behaviour is verified against the code on `9225ec2` (not against memory of earlier slices) and cites its source. A claim that cannot be verified is not written. |
| +- The README's first paragraph carries the scriptless-floor sentence (spec §9: browser media participation needs script, everything else works with scripts off) and the second paragraph is the relay paragraph (spec §5.2, §5.5: ICE servers come from the platform relay through `CARLOS_RELAY`, Meet names no STUN or TURN server of its own; with no relay, calls are direct only). |
| +- Nothing here restates a suite ruling; §0.2 names rulings by number (doctrine Appendix A intro: "delete the restatement, name the ruling"). |
| + |
| +## What slice 6 was, and what it is now |
| + |
| +The programme (2026-09-09) listed for slice 6: PRODUCT.md, the README with the floor and relay paragraphs first, AGENTS.md's final notes, the acknowledgements page, the *Consumed suite rulings* commit for suite Appendix A.6, the Estilo theme pin if a tagged release exists, and the production promotion with its notification. Since slice 5 shipped (a283c53, 2026-09-11) other sessions landed on main: Home sign-in (`7.1.1`, `internal/meet/home.go`), the shared Estilo day theme and brand (`f9cb47f`, `34fc367`; `amadan.net/oficina/estilo.git` as an indirect pseudo-version), the invitation `Share` route (`POST /m/{sqid}/share`, 2026-09-12 amendment), and the native Mac, iPhone and iPad apps (`native/`, `meetcore/`, `protocol/`, tag `v0.1.0` at `9225ec2`). Therefore: |
| + |
| +| Programme item | Disposition in this slice | |
| +|---|---| |
| +| PRODUCT.md | Task 2, here. | |
| +| README with floor and relay paragraphs first | Task 1, here. | |
| +| AGENTS.md final notes | Task 1, here (deploy and gate notes only; the file already carries the native and sharing notes added on main). | |
| +| Acknowledgements page | **Not built here.** `oficina/meet` discussion 10, branch `feat-open-source-credits` (`117b6e4`, `bbc126e`): a shared Estilo credits page with a runtime dependency inventory, Fable-approved, verified live on the overnight canary, not yet merged to main. Slice 6 records it and defers to it; building a second one would be the duplication the programme forbids. | |
| +| Estilo theme pin if a tagged release exists | **No tag exists** (`git ls-remote --tags` on `oficina/estilo` is empty on 2026-09-14). The theme is nevertheless already adopted on main at a pseudo-version by the Home integration. Recorded in §15.18; not changed. | |
| +| Consumed suite rulings commit | Task 3, here: §0.2 refreshed against doctrine main `1ac4323` (still revision 8) and against what main now consumes (Home hand-off, the Share amendment), in one commit titled so Appendix A.6 can point at it. | |
| +| Production promotion with its notification | **No deploy from this slice.** The change is documentation; edge (`meet.oficina.works`) is being promoted by hand by Paul (`651cff2` on 2026-09-12) with other sessions' work queued behind live checks (discussion 14). A docs-only redeploy would drop live calls for nothing. The notification posted on discussion 3 says so in those words; a queued promotion is never announced as live (programme §5). | |
| + |
| +--- |
| + |
| +### Task 1: README.md and AGENTS.md's final notes |
| + |
| +**Files:** |
| +- Create: `README.md` |
| +- Modify: `AGENTS.md` (append a short "Deploying" section after "Serving"; do not touch existing lines) |
| + |
| +**Interfaces:** |
| +- Consumes: spec §3.3/§3.4 (the floor sentence on the holding page and room: find the exact shipped sentence in `internal/meet/templates/join.html` and `room.html`), §5.2/§5.5 (relay), §7.1.1 (Home), §7.3 (deploy), §8 (agents), `native/README.md`, `Makefile` targets, `cmd/meet/main.go` (env vars), `internal/meet/config.go` (which are refused when empty outside development). |
| +- Produces: the README that PRODUCT.md (Task 2) and the record (Task 4) link to. |
| + |
| +- [ ] **Step 1: Verify each fact you will state** by reading the file it comes from on this branch. Keep a list of `claim → file:line` for the report. |
| + |
| +- [ ] **Step 2: Write `README.md`** with this shape (the paragraphs in this order; write them as prose, not as this outline): |
| + |
| +```markdown |
| +# Meet |
| + |
| +<Paragraph 1: what Meet is (video calls for one team: create or schedule a |
| +meeting, share a link, a holding screen, a room of up to four, chat, screen |
| +share, an agent API), and the floor sentence: every screen works with |
| +JavaScript disabled except browser media participation, the camera preview |
| +and join button on the holding screen and the call itself in the room; both |
| +pages render their title, standing, floor notice and navigation without |
| +script (spec §9).> |
| + |
| +<Paragraph 2: the relay. ICE servers come from the CARLOS platform relay, |
| +delivered as CARLOS_RELAY and minted per call at GET /m/{sqid}/ice; Meet |
| +names no STUN or TURN server of its own (suite ruling 6); with no relay |
| +configured, calls are direct only and the holding screen says so. Enabling |
| +or rotating the relay restarts the instance (spec §5.2, §5.5, §15.13).> |
| + |
| +## Sign-in |
| +<magic link to an allowlisted address (MEET_ALLOWED_ADDRESSES); a meeting |
| +link gives an outsider a guest grant at sign-in, never a way in without a |
| +name; optional Home sign-in with the four MEET_HOME_* settings, all or |
| +none (spec §7.1.1).> |
| + |
| +## The room |
| +<P2P mesh of up to four, perfect negotiation, SSE stream + POST signal, |
| +waiting queue for guests, removal and takeover through confirm pages, |
| +chat fanned out and never stored, screen share as a second tile; every |
| +deploy drops live calls because the hub is in-process (spec §4, §5, §6).> |
| + |
| +## Agents |
| +<Settings mints a token shown once; bearer-only /api/v1 (list, create, |
| +replace links, end, get); reach re-derived per request; 60/min per agent; |
| +spec §8 and §15.17.> |
| + |
| +## Native apps |
| +<one paragraph pointing at native/README.md and native/ios/README.md; |
| +make native-mac; same server and shared Go core; no embedded webviews.> |
| + |
| +## Building and gates |
| +<make ci (CGO_ENABLED=0), make race (the cgo exception, tests only), |
| +make browser (real Chromium, 31+ scenarios, about nine minutes, must run |
| +with the Claude Code bash sandbox off: UDP is blocked inside it), make |
| +native-test; make release VERSION=<sha> stamps /api/version.> |
| + |
| +## Deploying |
| +<unit route on CARLOS; carlos deploy --app meet --account oficina with the |
| +build probe on /api/version; secrets MEET_INSTANCE_KEY, MEET_DEK (32 base64 |
| +bytes), mail via MEET_SMTP_* with CARLOS_SMTP_* as fallback; env |
| +MEET_ORIGIN, MEET_ALLOWED_ADDRESSES, MEET_ZONE; carlos relay enable --app |
| +meet; migrations apply at boot once each (log line msg=migrate); every |
| +deploy restarts the instance and drops live calls, so deploy outside |
| +meeting hours; the canary recipe (carlos instances create + carlos deploy |
| +--channel) because carlos canary forms a two-label host, platform #124.> |
| + |
| +## Where things are written down |
| +<spec, programme plan, the slice records under docs/superpowers/reviews/, |
| +PRODUCT.md, AGENTS.md.> |
| +``` |
| + |
| +- [ ] **Step 3: Append to `AGENTS.md`**, after the "Serving" section, a "Deploying" section of at most eight lines: unit route; `carlos deploy` with the probe; migrations at boot, never re-run (already in the rules; say only that a database which applied a differently named migration file fails at boot); every deploy and every relay config change restarts the instance and drops live calls; the `carlos deploy` wait can time out before the hand-over, check `/api/version` before retrying; the canary recipe. No other change to AGENTS.md. |
| + |
| +- [ ] **Step 4: Check** `grep -c '' README.md` is under 140 lines, no sentence claims something you did not verify, no suite ruling is paraphrased where a number will do. |
| + |
| +- [ ] **Step 5:** Do not commit. Report the claim list. |
| + |
| +### Task 2: PRODUCT.md |
| + |
| +**Files:** |
| +- Create: `PRODUCT.md` |
| + |
| +**Interfaces:** |
| +- Consumes: `oficina/docs` `PRODUCT.md` as the shape (impeccable product schema 1: Platform, Users, Product Purpose, Positioning, Operating Context, Capabilities and Constraints, Brand Commitments, Evidence on Hand, Product Principles, Accessibility & Inclusion), the spec's §1 (goals), §9 (the floor), §11 (deliberately not built), §13 (gates), the slice records. |
| +- Produces: the section "What is deliberately not here" that spec §9 says slice 6 records the scriptless exception in. |
| + |
| +- [ ] **Step 1: Read** `/home/paulca/amadan.net/oficina/docs/PRODUCT.md` in full for the shape and the disclaimer header (written unattended; facts cited to spec or code; readings marked **[inferred — confirm]**). |
| + |
| +- [ ] **Step 2: Write `PRODUCT.md`** in that shape, for Meet, citing spec sections and files. It must contain a section **"What is deliberately not here"** listing: the scriptless-floor exception (browser media participation, spec §9); no reactions, dial-in, captions, multi-host signalling, recordings (§11, as amended on main); no STUN/TURN of Meet's own (ruling 6); no passkeys at the instance (as Docs); no SFU (roadmap 4b later); no client-address limiter on the API (§15.17); chat never stored; the room capped at four. Mark inferred readings. Under 240 lines. |
| + |
| +- [ ] **Step 3:** Do not commit. Report the claim list. |
| + |
| +### Task 3: The *Consumed suite rulings* commit and §15.18 |
| + |
| +**Files:** |
| +- Modify: `docs/superpowers/specs/2026-09-07-meet-design.md` (§0.2; §12 item 6; a new §15.18) |
| + |
| +**Interfaces:** |
| +- Consumes: doctrine main `1ac4323` revision 8 (`/home/paulca/amadan.net/oficina/suite/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`, verified current by `git ls-remote` on 2026-09-14), Appendix A's intro (name the ruling, delete the restatement), spec §7.1.1 and the 2026-09-12 sharing amendment on main, `internal/meet/home.go`, `internal/meet/api.go`. |
| +- Produces: the commit suite Appendix A.6 will cite (its hash goes on suite discussion 5 in Task 4). |
| + |
| +- [ ] **Step 1: Refresh §0.2.** Keep the table's shape. Confirm the doctrine citation (revision 8 at 1ac4323, still main on 2026-09-14). Update the row for rulings 4 and 12: the home now mints assertions and Meet consumes them through `home/connect` (§7.1.1) while the allowlist remains the membership authority; the row must name the rulings and §7.1.1, not restate them. Add a row for ruling 16 as amended by the 2026-09-12 sharing update (invitation tokens are hash-indexed with a sealed copy for members' repeat sharing; agent credentials remain hash-only and shown once). Keep "Also consumed" current (discussion 4; the media roadmap; the relay spec, now live). Do not paraphrase any ruling. |
| + |
| +- [ ] **Step 2: §12 item 6:** annotate as delivered on 2026-09-14 with the disposition table from this plan's "What slice 6 was" section in one sentence per item. |
| + |
| +- [ ] **Step 3: Add §15.18** "§12 item 6 — what slice 6 delivered, deferred and declined": the README and PRODUCT.md; the acknowledgements page deferred to discussion 10's branch; the Estilo pin (no tag exists; adopted at a pseudo-version by the Home integration on main; the programme's condition is recorded as unmet and the adoption as done by another route); no deploy from a docs-only slice, edge hand-managed at 651cff2. |
| + |
| +- [ ] **Step 4:** Do not commit. Report the exact lines changed. |
| + |
| +### Task 4: Record, reviews, merge, notification |
| + |
| +**Files:** |
| +- Create: `docs/superpowers/reviews/2026-09-14-slice-6-record.md` |
| + |
| +- [ ] **Step 1:** Commit Tasks 1 to 3 as three commits (README+AGENTS; PRODUCT.md; "Consumed suite rulings: §0.2 against doctrine 1ac4323 rev 8, §15.18"). |
| +- [ ] **Step 2:** Whole-branch review (Opus): every claim in the three documents checked against the code on the branch; findings W6-n. |
| +- [ ] **Step 3:** Codex round(s) until accept. |
| +- [ ] **Step 4:** Record in the shape of the slice 5 record: rulings, dispositions, carried items (the acknowledgements page, the Estilo tag, the production promotion, the credits inventory freshness gate that discussion 10 adds), the copy catalogue note, the gates (`make ci` on the head; the browser gate is not run: no page or client changed). |
| +- [ ] **Step 5:** `make ci`, push, remote CI, fast-forward main, the notification on `oficina/meet` discussion 3 and the checkpoint on `oficina/suite` discussion 5 naming the §0.2 commit for Appendix A.6, close the slice discussion, hub tasks Done, memory. |
diff --git a/docs/superpowers/reviews/2026-09-14-slice-6-record.md b/docs/superpowers/reviews/2026-09-14-slice-6-record.md| new file mode 100644 |
| index 0000000..85f8a03 |
| --- /dev/null |
| +++ b/docs/superpowers/reviews/2026-09-14-slice-6-record.md |
| @@ -0,0 +1,209 @@ |
| +# Meet slice 6 — the record |
| + |
| +Branch `slice/6-deploy`, `9225ec2..HEAD` (this commit; started on main |
| +`9225ec2`, tag `v0.1.0`). Spec: `docs/superpowers/specs/2026-09-07-meet-design.md`, |
| +revision 9 (§15.18 carries this slice's dispositions). Plan: |
| +`docs/superpowers/plans/2026-09-14-meet-slice-6-deploy.md`. |
| + |
| +Slice 6 is the programme's closing slice (spec §12 item 6) and is |
| +documentation only: `README.md`, `PRODUCT.md`, a Deploying section in |
| +`AGENTS.md`, the *Consumed suite rulings* commit (`fa875c7`, the hash |
| +suite Appendix A.6 points at) and the spec amendments that commit and |
| +the review made. No file under `internal/`, `cmd/`, `native/`, |
| +`meetcore/` or `protocol/` changed. The ledger is |
| +`.superpowers/sdd/2026-09-14-meet-slice-6-deploy/` (gitignored; this |
| +file is its substance). |
| + |
| +--- |
| + |
| +## 1. Rulings and decisions |
| + |
| +- **The slice was re-scoped against main as it is on 2026-09-14, not |
| + as the programme imagined it on 2026-09-09.** Between slice 5 |
| + (`a283c53`, 2026-09-11) and this slice, other sessions landed Home |
| + sign-in (§7.1.1), the shared Estilo day theme and brand, the |
| + invitation `Share` route (the 2026-09-12 amendment), and the native |
| + Mac, iPhone and iPad apps. The plan's "What slice 6 was, and what it |
| + is now" table is the disposition of each programme item; §12 item 6 |
| + and §15.18 restate it in the spec. |
| +- **The acknowledgements page is not built here.** `oficina/meet` |
| + discussion 10, branch `feat-open-source-credits` (`117b6e4`, |
| + `bbc126e`), carries a shared Estilo credits page with a runtime |
| + dependency inventory and its freshness gate, Fable-approved and |
| + verified live on the overnight canary. Building a second one would |
| + be the duplication the programme forbids. |
| +- **The Estilo theme pin.** The programme's condition was a tagged |
| + release; `git ls-remote --tags` on `oficina/estilo` is empty on |
| + 2026-09-14. The theme is nevertheless adopted on main at an indirect |
| + pseudo-version (`go.mod`), brought in by the Home integration. |
| + Recorded as: condition unmet, adoption done by another route, |
| + nothing changed here. |
| +- **No deploy from this slice.** The change is documentation, |
| + checkable from this diff. As observed on 2026-09-14 from `carlos |
| + channels` and `oficina/meet` discussions 11 and 14, edge |
| + (`meet.oficina.works`) was being promoted by hand by Paul (`651cff2` |
| + on 2026-09-12), with discussion 14's web UI work queued behind a |
| + live check — state as of that observation, not asserted as current |
| + here. A redeploy would drop live calls for nothing; a queued |
| + promotion is never announced as live (programme §5). The domain was |
| + attached before this slice. |
| +- **Copy review is waived** for this round by Paul's steering on |
| + `oficina/suite` discussion 5. README and PRODUCT.md prose is |
| + catalogued here (§3) rather than reviewed. |
| +- **Writers did not commit.** Three writers worked concurrently in one |
| + worktree on disjoint files; the controller committed each set. The |
| + index is the shared resource, not the files. |
| +- **Every behavioural claim is verified against the code on the |
| + branch and cites its source.** The whole-branch review's finding was |
| + that the writers verified the documents against the code but not the |
| + code's documents against the documents: four spec sentences on main |
| + asserted things about "the README" that the README, once it existed, |
| + did not say (§0.1/§5.6's encryption sentence, §5.5's TCP-443 cause |
| + and "first paragraph", §3.2's residue). All four now hold, and the |
| + fix wave swept every spec sentence naming README, AGENTS.md or |
| + PRODUCT.md (twelve remaining, all true). |
| +- **The two meanings of "floor".** §5.5's floor is "no relay"; §9's is |
| + the scriptless floor. §12 item 6 now says which goes where (the |
| + scriptless sentence in the README's first paragraph, the relay |
| + paragraph carrying §5.5's sentence second); §15.18 notes the two |
| + senses. |
| +- **Platform facts stated from this project's own deploys**, not from |
| + the platform source: `carlos canary` composed a host the console |
| + refused (platform discussion 124, open); the recipe used is |
| + `carlos instances create --host <name>-meet.bds.oncarlos.com |
| + --channel canary/<name> --environment <name>` then `carlos deploy |
| + --channel …`, with the environment bundle mandatory because a canary |
| + without one serves `MEET_ORIGIN` for the main host and refuses |
| + sign-in while GETs look healthy. The release command is `carlos |
| + deploy --app meet --account oficina --channel edge --version <sha> |
| + --target linux-arm64 releases/meet-linux-arm64` after `make release |
| + VERSION=<sha>`; the deploy wait can time out before the hand-over |
| + while the instance serves the new version a minute later. |
| +- **Ruling 16** stays cited as on the doctrine's |
| + `worktree-worktree-token-at-rest` branch, not doctrine main (whose |
| + rulings run 1 to 15 at `1ac4323`, revision 8, confirmed current by |
| + `ls-remote` on 2026-09-14), as amended by the 2026-09-12 sharing |
| + update for invitation tokens; agent credentials remain hash-only and |
| + shown once. |
| + |
| +--- |
| + |
| +## 2. Per-task review dispositions |
| + |
| +Three writing tasks (Sonnet) in parallel, then one whole-branch review |
| +(Opus) and one fix wave, then Codex rounds. |
| + |
| +- **Task 1, README and AGENTS.md** (`93f1c2e`): 106 lines, the two |
| + paragraphs first. Flagged and acted on: spec §11 still listed a |
| + native client as not built (`aa9b227`). Flagged and initially left, |
| + then closed by the review: §5.5's "first paragraph" cross-reference; |
| + `MEET_SMTP_PASS` as a secret. |
| +- **Task 2, PRODUCT.md** (`e684b97`): impeccable product schema 1 in |
| + `oficina/docs`' shape with the unattended-writing disclaimer; "What |
| + is deliberately not here" carries the scriptless exception spec §9 |
| + asked for. |
| +- **Task 3, the consumed-rulings commit** (`fa875c7`): §0.2's 4/12 row |
| + consumes the home's assertions through §7.1.1 with the allowlist as |
| + membership authority; a ruling-16 row as amended; §12 item 6 |
| + annotated; §15.18 added. Revision line left at 9, following what |
| + §15.13 to §15.17 did. |
| +- **Whole-branch review** (`e684b97`): verdict **merge with named |
| + fixes**, W6-1 to W6-15, all applied in `bf3b86b` and `c22cae7` |
| + (reflow), plus the handoff's spec sweep, the two-floors |
| + disambiguation, §15.11's stale "until slice 3, or until a drain |
| + exists" (no drain exists; every deploy drops live calls) and |
| + `token_sealed` added to §2.1's `meeting_links` columns. Roughly |
| + sixty behavioural claims were traced to file and line and found |
| + correct; the fifteen findings were the remainder. |
| + |
| +**Codex round 1** (independent, static, scope `9225ec2..c22cae7`): |
| +verdict **request changes** — thirteen findings, S6-1 through S6-13. |
| +All applied in one commit (`.superpowers/sdd/2026-09-14-meet-slice-6-deploy/fix-wave-report.md`, |
| +round-1 section): |
| + |
| +- **S6-1 — P2, deployment settings undocumented. Fixed:** README's Deploying section enumerates every required setting and which refuse startup outside `-env development`. |
| +- **S6-2 — P2, the deploy-wait guarantee had an unstated prerequisite. Fixed:** README and AGENTS.md now state the `carlos apps build-probe` prerequisite and the `X-Carlos-Version` fallback it stands in for. |
| +- **S6-3 — P2, the scriptless exception was narrower than shipped. Fixed, then completed in Codex round 2 (S6-16):** README, PRODUCT.md and spec §9 name the chat panel and the live admission controls as script-gated too, alongside browser media participation; AGENTS.md's two matching lines, left behind in round 1, were widened the same way, so §9's "the binding file and the code agree" holds again; §15.18 notes both. |
| +- **S6-4 — P2, the cgo claims didn't match the Makefile. Fixed, then corrected again in Codex round 2 (S6-14):** README and PRODUCT.md say `CGO_ENABLED=0 make ci` and name `make race` and `make native-test`'s `meetcore` sub-step as the two `CGO_ENABLED=1` cases that never ship; the native Mac and iPhone/iPad apps are named separately as the one place real cgo does ship (`MeetBridge`, linked by `MeetKit`), while server releases stay cgo-free. |
| +- **S6-5 — P2, the SMTP fallback description was backwards. Fixed:** README now says the platform supplies `CARLOS_SMTP_FROM` too, which `MEET_SMTP_FROM` optionally overrides. |
| +- **S6-6 — P2, suite restatements remained under §0.2. Fixed, then completed in Codex round 2 (S6-15):** rows 3, 6, 9 and 15 cite section and ruling number only; the discussion-4 paragraph does too; the §5.2 paragraph, shortened but not emptied in round 1, is now the bare citation "Doctrine §5.2 is also relied on, for §2."; PRODUCT.md's actor sentence likewise; ruling 16's off-main provenance kept as before. |
| +- **S6-7 — P2, PRODUCT.md cited a section that denies native clients shipped. Fixed:** spec §8.4 gained a 2026-09-14 amendment recording the shipped milestone (`v0.1.0` at `9225ec2`) and the browser-approval handoff (`86ed9f3`); PRODUCT.md's two native-client citations now point at that amendment. |
| +- **S6-8 — P2, the shipped same-network sentence was missing from the README. Fixed:** the relay paragraph now quotes `join.html`'s floor sentence verbatim. |
| +- **S6-9 — P2, the route inventory undercounted confirmation pairs. Fixed:** PRODUCT.md now says "five destructive confirmation pairs"; spec §9 now says "every confirm page (five)". |
| +- **S6-10 — P3, Share's legacy behavior was omitted. Fixed:** README now distinguishes a legacy meeting's first Share (mints a new recoverable invitation alongside the old one) from every retrieval after. |
| +- **S6-11 — P3, "60 requests a minute" obscured the permitted burst. Fixed:** README and PRODUCT.md now say "one request a second sustained, with a burst of 60". |
| +- **S6-12 — P2, the canary creation command relied on undocumented app/account defaults. Fixed:** README's `instances create` now carries `--app meet --account oficina` and states the named environment bundle must already exist, created separately. |
| +- **S6-13 — P3, disposition coverage and external-state attribution were incomplete. Fixed, then completed in Codex round 2 (S6-17):** README and PRODUCT.md link to spec §15.18 as the disposition of record; the platform-124, no-Estilo-tag and credits-branch-canary claims are attributed to dated records. Round 1 left the production-promotion state itself (edge's served commit, the queued work) asserted as current fact in §15.18 and this record's own §3; both now attribute it to what `carlos channels` and `oficina/meet` discussions 11 and 14 showed on 2026-09-14, keeping "this slice changes documentation only" as the one claim checkable from the repository. |
| + |
| +**Codex round 2** (independent, static, scope `2a6eee4`): verdict |
| +**request changes** — four findings, S6-14 through S6-17, against |
| +round 1's own fixes; the other twelve confirmed fixed as claimed |
| +(table in the round's own report). All four applied in one commit |
| +(`.superpowers/sdd/2026-09-14-meet-slice-6-deploy/fix-wave-report.md`, |
| +round-2 section): |
| + |
| +- **S6-14 — P2, PRODUCT.md still claimed the native cgo bridge never ships. Fixed:** "three things run under `CGO_ENABLED=1`, none of them shipped" is now two — `make race` and `make native-test`'s `meetcore` sub-step, both test-only — with the native Mac and iPhone/iPad apps named separately as the one place real cgo does ship (`MeetBridge`, a `native/apple/Package.swift` binary target `MeetKit` links, built by `native/scripts/build-mac.sh`), while server releases stay `CGO_ENABLED=0`. This record's own S6-4 line is corrected to match. |
| +- **S6-15 — P2, the guest-grant sentence was shortened, not removed. Fixed:** "Doctrine §5.2 is also relied on, for §2: a guest grant is stored, not asked for" drops its second clause — still a paraphrase of doctrine §5.2's grant authority — leaving the bare citation "Doctrine §5.2 is also relied on, for §2." |
| +- **S6-16 — P2, the widened scriptless exception left `AGENTS.md` behind. Fixed:** `AGENTS.md`'s two scriptless lines (the "Screens work with JavaScript disabled" rule and the UX-conventions sentence) now name the chat panel and admission controls alongside browser media participation, matching §9, README and PRODUCT.md; §9's own "the binding file and the code agree" is corrected to say when each half landed; §15.18 records the correction. |
| +- **S6-17 — P3, production state was asserted as current rather than attributed. Fixed:** §15.18's production-promotion bullet and this record's own §1 and §3 now attribute edge's served commit and the queued web UI work to what `carlos channels` and `oficina/meet` discussions 11 and 14 showed on 2026-09-14, rather than presenting it as established from this repository; "this slice changes documentation only" stays stated as fact, since the diff itself proves it. |
| + |
| +**Codex round 3** (independent, static, scope `cd02a59`): verdict |
| +**accept** — S6-14 to S6-17 closed, no new finding, "fit to merge as |
| +documentation of the code on this branch". Three rounds in all: 13, 4, |
| +0 findings. |
| + |
| +--- |
| + |
| +## 3. Carried items, and where they go |
| + |
| +- **The acknowledgements page.** Discussion 10's branch |
| + `feat-open-source-credits`, not yet on main. **Destination: that |
| + branch's owner**; when it merges, the README's "Where things are |
| + written down" section should gain the credits page's path (one |
| + line). |
| +- **The Estilo theme tag.** No tagged release exists; the pseudo-version |
| + pin stays until one does. **Destination: oficina/estilo.** |
| +- **The production promotion of main's head.** As observed on |
| + 2026-09-14 from `carlos channels` and `oficina/meet` discussions 11 |
| + and 14, edge was serving `651cff2` with discussion 14's web UI work |
| + queued behind it — state as of that observation, not asserted as |
| + current here. Main is at `9225ec2` plus this slice, checkable from |
| + this repository: `9225ec2` changed native code only, and this slice |
| + changes documents only. **Destination: Paul**, who promotes by hand. |
| +- **Unauthenticated flood control on `/api/v1`** (from slice 5). |
| + **Destination: carlos/platform**, unchanged. |
| +- **Platform discussion 124** (`carlos canary` and the one-label host). |
| + Open. The README carries the recipe that works. **Destination: |
| + carlos/platform.** |
| +- **Spec §9's "four confirm pages" is five** (remove, takeover, |
| + replace-link, end, revoke-agent). **Resolved in Codex round 1 |
| + (S6-9):** §9 now says "every confirm page (five)" and PRODUCT.md |
| + says "five destructive confirmation pairs". |
| +- **Copy catalogue** (copy review waived): the README's and |
| + PRODUCT.md's prose are the new user-facing text this slice adds; |
| + neither is an app string. Nothing in `internal/meet/templates` |
| + changed. |
| +- **The ledger's process note.** A writer's flag should become a task, |
| + not a paragraph: two of the three task reports flagged the right |
| + problems (W6-3, W6-6) in their "not corrected" sections and the |
| + controller took them as recorded rather than as work. |
| + |
| +--- |
| + |
| +## 4. Deploying this slice |
| + |
| +Nothing to deploy. No binary, migration, template, client or |
| +configuration changed. When main is next promoted, the README's |
| +Deploying section is the recipe, and the `carlos deploy` wait note in |
| +§1 applies. |
| + |
| +--- |
| + |
| +## 5. The gates, and how to run them |
| + |
| +| Gate | Command | This slice | |
| +|---|---|---| |
| +| CI | `CGO_ENABLED=0 make ci` | Green on every commit of the branch; remote CI passed on `e684b97` and `c22cae7`. Markdown cannot fail it; it is run so the branch's head is known green. | |
| +| Race | `make race` | Not run: no change under `internal/meet/hub` or the stream. | |
| +| Browser | `make browser` (sandbox off) | Not run: no page, template or client file changed. The last run on a shipping head is slice 5's, 31 of 31 on `4f896c2`. | |
| +| Native | `make native-test` | Not run: nothing under `native/`, `meetcore/` or `protocol/` changed. | |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index f4a7dba..8eae61f 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -89,20 +89,17 @@ This app cites the suite sharing doctrine (`oficina/suite`, |
| | Ruling | Used for | |
| |---|---| |
| | 1, 5 | One instance per team; the first deploy is the **lone shape** (§7.1). | |
| -| 3 | Authorization is asked per request and never cached. Standing (member, guest, neither) is derived at each request and re-derived on every hub tick (§2.3). | |
| -| 4, 12 | Identity and roster belong to the home. Until the home mints assertions, the lone instance keeps its own allowlist exactly as Docs and Slides do. | |
| -| 6 | No product-wide egress. Meet never names a STUN or TURN server of its own; ICE servers come from the platform (§5.2) or there are none (§5.5). | |
| -| 9 | Every write names its real actor: a member, a guest who signed in, or an agent (§8). | |
| -| 15 | Three rungs plus a read-only link. A meeting link is a capability to obtain a **guest grant at sign-in**, the `granted` rung, and never a way into a room without a name (§3). Ruled for Meet on `oficina/suite` discussion 3, Paul, 2026-09-06. | |
| -| 16 | A link token is stored hashed and shown once (§3.1). Ruling 16 is on the doctrine's `worktree-worktree-token-at-rest` branch (suite discussion 2), not yet on main; Meet adopts it now so it never has the Docs defect that produced it. | |
| - |
| -Doctrine §5.2 is also relied on: a guest grant held by the instance is |
| -authoritative for that grant, which is why §2 stores it and does not |
| -ask anyone. |
| - |
| -Also consumed: `oficina/suite` discussion 4 (agents as their own |
| -principal, on behalf of a person, bound to the operator's reach and no |
| -more) for the drive surface (§8); the CARLOS media roadmap |
| +| 3 | §2.3: standing (member, guest, neither) is derived at each request and re-derived on every hub tick. | |
| +| 4, 12 | The home now mints assertions; Meet consumes them through `home/connect`'s bridge, resolving to the existing person and re-checking membership on every request, while the allowlist remains the membership authority throughout (§7.1.1). | |
| +| 6 | §5.2, §5.5: Meet never names a STUN or TURN server of its own; ICE servers come from the platform, or there are none. | |
| +| 9 | §8: a member, a guest who signed in, or an agent. | |
| +| 15 | §3: a meeting link is a capability to obtain a **guest grant at sign-in**, the `granted` rung, and never a way into a room without a name. Ruled for Meet on `oficina/suite` discussion 3, Paul, 2026-09-06. | |
| +| 16 | The token at rest (§3.1), as amended by the 2026-09-12 sharing update at the top of this file: invitation tokens are hash-indexed with a sealed copy so a member can share the current one again; agent credentials stay hash-only and shown once (§15.17). Ruling 16 is on the doctrine's `worktree-worktree-token-at-rest` branch (suite discussion 2), not on main on 2026-09-14; Meet adopts it early so it never has the Docs defect that produced it. | |
| + |
| +Doctrine §5.2 is also relied on, for §2. |
| + |
| +Also consumed: `oficina/suite` discussion 4, for the drive surface |
| +(§8); the CARLOS media roadmap |
| (`carlos/platform`, |
| `docs/superpowers/specs/2026-09-07-media-platform-roadmap-design.md`, |
| merged at 8291cb2) and the stage 4a relay spec |
| @@ -171,9 +168,12 @@ that the subject is an address, not an id. Standing is not a column |
| its links answer 404 exactly as a revoked one does. |
| **`meeting_links`** — `id`, `meeting_id`, `token_hash` (SHA-256 of the |
| -token, ruling 16), `created_by`, `created_at`, `revoked_at`. One meeting |
| -may hold several links so a revoked one can be replaced. The token is |
| -returned once, in the response that minted it, and never again. |
| +token, ruling 16), `token_sealed` (nullable; added by the 2026-09-12 |
| +sharing amendment, top of file, so a member can retrieve a current |
| +invitation again), `created_by`, `created_at`, `revoked_at`. One |
| +meeting may hold several links so a revoked one can be replaced. The |
| +token is returned once, in the response that minted it, and again on |
| +demand through `POST /m/{sqid}/share` once a sealed copy exists. |
| **`pending_joins`** — `id`, `meeting_id`, `link_id` (the |
| `meeting_links` row the browser followed), `address_fingerprint` (keyed |
| @@ -823,9 +823,10 @@ is usually true on a permissive local network, usually false across the |
| internet, and false on a local network with client isolation. The |
| pre-join screen says, above the button: "This team's Meet has no relay |
| yet. Calls usually connect only between people on the same network." |
| -The README's first paragraph says the same. An operator enables the |
| -platform relay with `carlos relay enable --app meet`, after which this |
| -paragraph no longer applies to that instance. |
| +The README's second paragraph, the one that introduces the relay, says |
| +the same; its first paragraph carries the scriptless floor (§9). An |
| +operator enables the platform relay with `carlos relay enable --app |
| +meet`, after which this paragraph no longer applies to that instance. |
| With `CARLOS_RELAY` present, a failed connection's tile says "could not |
| connect, even through the relay" and offers retry. It does not diagnose |
| @@ -974,6 +975,19 @@ A thin adapter over §8.2, deferred until a consumer asks by name. |
| ### 8.4 Native clients |
| +**2026-09-14 amendment.** The SwiftUI Mac, iPhone and iPad apps this |
| +section specifies shipped on `main` (`native/`, `meetcore/`, |
| +`protocol/`, tag `v0.1.0` at `9225ec2`), closing the authentication gap |
| +the bullets below name with a browser-approval hand-off |
| +(`native/README.md`): a five-minute, one-use authorization bound to a |
| +proof secret held only by the app; approving in the browser creates an |
| +ordinary framework session, no cookie copied and no new bearer type |
| +introduced. The production handoff endpoints were deployed in |
| +`86ed9f3`. This supersedes the closing paragraph's "version one does |
| +not ship a native client" below; the rest of this section, written |
| +before that milestone, otherwise stands as the contract the shipped |
| +apps speak. |
| + |
| Ruling 15: a native app is the next client after the browser, and |
| nothing in Meet's server contracts may assume the browser. Concretely, |
| in version one: |
| @@ -1000,19 +1014,25 @@ until an OpenAPI document earns its place. |
| ## 9. The scriptless floor |
| -The exception is **browser media participation** and nothing wider: |
| -reaching a camera and another browser needs script. Every other screen |
| -works with scripts off, including the meeting list, creating a meeting, |
| -seeing and copying a link, all four confirm pages, sign-in and |
| -continuation, the `/welcome` page, the agent token page, and the server-rendered |
| -parts of the holding screen and the room (§3.3, §3.4): title, start |
| -time, standing, floor sentence, the paragraph that says what needs |
| -script, and navigation to everything that does not. |
| - |
| -`AGENTS.md`'s line "screens work with JavaScript disabled" gains, in |
| -slice 2, the one exception in those words, so the binding file and the |
| -code agree from the commit that introduces the exception. `PRODUCT.md` |
| -records it in "what is deliberately not here" in slice 6. |
| +The exception is **browser media participation**: reaching a camera |
| +and another browser needs script. Inside a room that exists, script |
| +also gates the chat panel (`chat.html`'s whole panel is rendered |
| +`hidden` until script un-hides it, because a Send button with no |
| +script behind it would be a lie, §6, §10) and the admission queue's |
| +Admit controls (`room.js`), since neither can do anything before a |
| +call does. Every other screen works with scripts off, including the |
| +meeting list, creating a meeting, seeing and copying a link, every |
| +confirm page (five), sign-in and continuation, the `/welcome` page, |
| +the agent token page, and the server-rendered parts of the holding |
| +screen and the room (§3.3, §3.4): title, start time, standing, floor |
| +sentence, the paragraph that says what needs script, and navigation to |
| +everything that does not. |
| + |
| +`AGENTS.md`'s line "screens work with JavaScript disabled" gained, in |
| +slice 2, the one exception in those words; on 2026-09-14, alongside |
| +this section's own widening, it gained the other two (§15.18), so the |
| +binding file and the code agree again. `PRODUCT.md` records it in |
| +"what is deliberately not here" in slice 6. |
| Hand-written static JavaScript, no bundler, no npm, in three files: |
| `devices.js` (the holding screen), `room.js` (signalling, resume and |
| @@ -1040,8 +1060,7 @@ are drafts for that review, not approved copy. |
| Recurring meetings. Calendar integration. Recording. Attendance |
| records. A relay of Meet's own. Rooms above four. Persisted chat. |
| Reactions. Dial-in. Captions. Multi-host signalling. |
| -Push-to-talk. Virtual backgrounds. A native client and its sign-in |
| -(§8.4). Denying a knock (a guest who is not |
| +Push-to-talk. Virtual backgrounds. Denying a knock (a guest who is not |
| admitted simply waits or leaves). Each is named so the next session |
| does not rediscover it. |
| @@ -1082,9 +1101,22 @@ own gates from §13; nothing is deferred to the deploy slice. |
| 5. **Drive surface.** `agents`, the five operations, the per-request |
| reach check, the badge. |
| 6. **Deploy.** Domain, secrets, canary, `PRODUCT.md`, the README with |
| - the floor sentence in its first paragraph and the relay paragraph, |
| - and the `Consumed suite rulings` commit that suite Appendix A.6 will |
| - point at. |
| + the scriptless-floor sentence (§9) in its first paragraph and the |
| + relay paragraph, carrying §5.5's no-relay sentence, second, and the |
| + `Consumed suite rulings` commit that suite Appendix A.6 will point |
| + at. |
| + |
| + **Delivered 2026-09-14 (§15.18).** The README, with the floor and |
| + relay paragraphs first, was written. `PRODUCT.md` was written. |
| + AGENTS.md's deploy notes were appended. This is the `Consumed suite |
| + rulings` commit, refreshing §0.2 against doctrine main `1ac4323` |
| + revision 8. The acknowledgements page is deferred to `oficina/meet` |
| + discussion 10's branch `feat-open-source-credits`, not duplicated |
| + here. The Estilo theme pin did not happen, because no tagged release |
| + exists; the theme is nonetheless already adopted on main at a |
| + pseudo-version through the Home integration. No production |
| + promotion happened, because this slice is documentation only; edge |
| + stays hand-managed by Paul. |
| --- |
| @@ -1482,8 +1514,9 @@ they were otherwise recorded only in an untracked ledger: |
| not solved). Slice 2 is the first release where that has a |
| user-visible cost: **every deploy drops every live call.** Members |
| rejoin automatically; guests go back to waiting and must be admitted |
| - again. Deploys should avoid meeting hours until slice 3, or until a |
| - drain exists. |
| + again. No drain exists, and slice 3 shipping did not change that: |
| + every deploy still drops every live call, so deploying during |
| + meeting hours means exactly that. |
| ### 15.12 §2.5 — the last row corrected: standing is not consulted *for a guest* |
| @@ -2130,3 +2163,58 @@ truncated before the `~` — the badge's icon needs no separate |
| every icon "sits beside its own visible text label") is already |
| satisfied by the badge's own word, alongside AGENTS.md's own line — |
| "State — never colour alone — always a text label too." |
| + |
| +### 15.18 §12 item 6 — what slice 6 delivered, deferred and declined |
| + |
| +Slice 6 is documentation only: nothing under `internal/`, `native/`, |
| +`meetcore/` or `protocol/` changed. Against the programme's slice 6 |
| +line (`docs/superpowers/plans/2026-09-09-meet-slices-2-6-programme.md` |
| +§5) and this spec's own §12 item 6: |
| + |
| +The word "floor" names two different things in this spec: §5.5's "The |
| +floor" is the no-relay condition, and §9's "The scriptless floor" is |
| +the JavaScript exception; item 6's bullet above now names each rather |
| +than leaving the bare word to carry both. |
| + |
| +§9's exception was also corrected here, on Codex round 1: browser |
| +media participation was never the only script-gated part of a room — |
| +the live admission controls have needed script since slice 2 and the |
| +chat panel since slice 4 — and §9, the README and `PRODUCT.md` now say |
| +so rather than "and nothing wider." Codex round 2 found the binding |
| +file left behind: `AGENTS.md` still named only the media exception, so |
| +§9's own "the binding file and the code agree" was false again the |
| +moment §9 widened. `AGENTS.md`'s two scriptless lines were widened to |
| +match on 2026-09-14, in the same commit. |
| + |
| +- **Delivered.** `README.md`, with the scriptless floor sentence and |
| + the relay paragraph first. `PRODUCT.md`. `AGENTS.md`'s deploy notes. |
| + §0.2 above, refreshed against doctrine main `1ac4323`, still |
| + revision 8 on 2026-09-14 — this is the `Consumed suite rulings` |
| + commit suite Appendix A.6 will point at. The domain was already |
| + attached outside this slice — `meet.oficina.works` serves the |
| + flagship instance (§7.3's `carlos domains attach`) — so item 6's |
| + domain row needed nothing from slice 6. |
| +- **Deferred, not duplicated.** The acknowledgements page lives on |
| + `oficina/meet` discussion 10, branch `feat-open-source-credits` |
| + (`117b6e4`, `bbc126e`): a shared Estilo credits page with a runtime |
| + dependency inventory. As recorded on that branch's own review, it is |
| + Fable-approved and was verified live on the overnight canary, not |
| + yet merged to main — not independently reverified here. Building a |
| + second one here would be the duplication the programme forbids. |
| +- **Declined: the Estilo theme pin.** The programme's condition for |
| + pinning was a tagged release; `git ls-remote --tags` against |
| + `oficina/estilo` is empty on 2026-09-14, so no tag exists and no pin |
| + was made. The theme is adopted on main anyway, at a pseudo-version |
| + (`amadan.net/oficina/estilo.git`), by the Home integration |
| + (`f9cb47f`, `34fc367`) — the programme's condition is recorded here |
| + as unmet, and the adoption as done by another route, not as the |
| + condition having been met after all. |
| +- **Declined: the production promotion.** A docs-only slice has |
| + nothing to promote that changes behaviour, and a redeploy would drop |
| + every live call for no reason (§15.11). As observed on 2026-09-14 |
| + from `carlos channels` and `oficina/meet` discussions 11 and 14, |
| + `meet.oficina.works` was being promoted by hand by Paul (`651cff2`, |
| + 2026-09-12) with other sessions' work queued behind live checks — |
| + 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. |
diff --git a/internal/meet/icons/icons.go b/internal/meet/icons/icons.go| index ce5c344..0289e41 100644 |
| --- a/internal/meet/icons/icons.go |
| +++ b/internal/meet/icons/icons.go |
| @@ -20,11 +20,12 @@ package icons |
| 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>`, |
| - "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>`, |
| - "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>`, |
| - "log-out": `<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="M4 9v10a2 2 0 0 0 2 2h6M4 9V5a2 2 0 0 1 2-2h6M10 12h12m-4-4 4 4-4 4"/></svg>`, |
| + "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>`, |
| + "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>`, |
| + "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>`, |
| + "log-out": `<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="M4 9v10a2 2 0 0 0 2 2h6M4 9V5a2 2 0 0 1 2-2h6M10 12h12m-4-4 4 4-4 4"/></svg>`, |
| "alert-triangle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`, |
| // class carries "lucide-bot" alongside the app's own "icon" so the |
diff --git a/internal/meet/static/devices.js b/internal/meet/static/devices.js| index 40ad506..f824cc4 100644 |
| --- a/internal/meet/static/devices.js |
| +++ b/internal/meet/static/devices.js |
| @@ -25,6 +25,12 @@ |
| const pickCamera = $('pick-camera'); |
| const pickMic = $('pick-mic'); |
| const pickSpeaker = $('pick-speaker'); |
| + const rawRow = $('raw-row'); |
| + // field-check renders a real checkbox inside its label and gives it no |
| + // id of its own, so it is reached through the row the page wraps it in |
| + // rather than by an id this file would have to agree with twice. |
| + const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null; |
| + const rawHelp = $('raw-help'); |
| const muteButton = $('toggle-mute'); |
| const cameraButton = $('toggle-camera'); |
| const muteState = $('toggle-mute-state'); |
| @@ -61,6 +67,70 @@ |
| } |
| } |
| + // audioConstraints builds the microphone half of a getUserMedia |
| + // request out of the stored preferences. It is a pure function of |
| + // `prefs` so the gate can lift it out of both files and check the two |
| + // copies still agree (test/browser/audio.test.mjs). room.js carries |
| + // it verbatim, because the room asks for a stream of its own at join |
| + // — the holding screen's is stopped by then — and a choice that held |
| + // on one page and not the other would be worse than no choice. |
| + // |
| + // The three flags are the browser's voice pipeline: echo |
| + // cancellation, noise suppression and automatic gain. Every browser |
| + // turns all three on for a microphone unless told otherwise, which is |
| + // right for a laptop on a kitchen table and wrong for an interface in |
| + // a room someone has treated — gain riding a held note, noise |
| + // suppression taking a cello for a fan. `rawAudio` is the person |
| + // saying which of those two rooms they are in. |
| + // |
| + // They are asked for as ideals and never as `exact`. A browser that |
| + // cannot turn its processing off should hand over a processed |
| + // microphone, not refuse getUserMedia and cost the person their |
| + // voice — the same rule the device ladder is built on. It is also why |
| + // the holding screen checks afterwards what it was actually given |
| + // (rawHonoured) instead of trusting that it got what it asked for. |
| + // |
| + // voiceIsolation is the newer, heavier filter in Safari and Chrome, |
| + // and it is not everywhere. An unknown constraint name in this basic, |
| + // non-exact form is dropped rather than refused, so naming it costs |
| + // nothing in a browser that has never heard of it. |
| + function audioConstraints(prefs) { |
| + const processed = !prefs.rawAudio; |
| + const constraints = { |
| + echoCancellation: processed, |
| + noiseSuppression: processed, |
| + autoGainControl: processed, |
| + voiceIsolation: processed, |
| + }; |
| + if (prefs.mic) constraints.deviceId = { exact: prefs.mic }; |
| + return constraints; |
| + } |
| + |
| + // rawHonoured answers whether the stream really did come back |
| + // unprocessed: true, false, or null when the browser reports none of |
| + // the settings and there is nothing honest to say. getSettings is the |
| + // only place the answer lives, because audioConstraints asks as an |
| + // ideal and an ideal may simply be ignored. Someone who turned the |
| + // processing off for an interface deserves to be told it did not take, |
| + // rather than spending the call hunting for the filter in their own |
| + // gear. |
| + function rawHonoured(stream) { |
| + const track = stream && stream.getAudioTracks()[0]; |
| + if (!track || typeof track.getSettings !== 'function') return null; |
| + const settings = track.getSettings(); |
| + const reported = ['echoCancellation', 'noiseSuppression', 'autoGainControl'] |
| + .filter((key) => typeof settings[key] === 'boolean'); |
| + if (!reported.length) return null; |
| + return reported.every((key) => settings[key] === false); |
| + } |
| + |
| + // rawNotice is the sentence startPreview ends on: empty when there is |
| + // nothing to report, which is the normal case both ways round. |
| + function rawNotice() { |
| + if (!prefs.rawAudio || rawHonoured(stream) !== false) return ''; |
| + return 'This browser kept its own echo cancellation and noise suppression on, so studio audio had no effect here.'; |
| + } |
| + |
| function say(text) { |
| status.textContent = text; |
| } |
| @@ -104,6 +174,22 @@ |
| savePrefs(); |
| applyToggles(); |
| }); |
| + // Studio audio is a capture setting and not a call control, so it is a |
| + // checkbox in the pickers rather than a fourth button in the row of |
| + // toggles: it changes what the microphone sends, not whether it is |
| + // sending. Changing it re-asks for the stream, because the processing |
| + // is chosen when the track is opened and not afterwards. |
| + if (rawInput) { |
| + if (rawHelp) rawInput.setAttribute('aria-describedby', 'raw-help'); |
| + rawInput.checked = !!prefs.rawAudio; |
| + rawInput.addEventListener('change', () => { |
| + prefs.rawAudio = rawInput.checked; |
| + savePrefs(); |
| + startPreview().catch((err) => { |
| + console.error('meet: preview after a studio audio change', err); |
| + }); |
| + }); |
| + } |
| joinButton.addEventListener('click', () => { |
| stopPreview(); |
| const href = root.dataset.joinHref || ''; |
| @@ -146,7 +232,7 @@ |
| stopPreview(); |
| say('Asking for your camera and microphone…'); |
| const constraints = { |
| - audio: prefs.mic ? { deviceId: { exact: prefs.mic } } : true, |
| + audio: audioConstraints(prefs), |
| video: prefs.camera ? { deviceId: { exact: prefs.camera } } : true, |
| }; |
| let result; |
| @@ -183,7 +269,7 @@ |
| applyToggles(); |
| joinButton.disabled = false; |
| await fillPickers().catch(() => {}); |
| - say(''); |
| + say(rawNotice()); |
| } |
| // disambiguate settles which remembered device id is stale, then |
| @@ -260,6 +346,8 @@ |
| // reflected immediately. |
| cameraRow.hidden = cameraCount === 0; |
| micRow.hidden = micCount === 0; |
| + // No microphone is no processing to choose about. |
| + if (rawRow) rawRow.hidden = micCount === 0; |
| speakerRow.hidden = !hasSinkId || speakerCount === 0; |
| } |
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css| index 003c4b1..bdb9dc9 100644 |
| --- a/internal/meet/static/meet.css |
| +++ b/internal/meet/static/meet.css |
| @@ -114,6 +114,22 @@ |
| min-height: 1.2em; |
| } |
| +/* Studio audio takes a line of its own below the device selects: it |
| + carries a sentence of explanation, and in the pickers' flex row that |
| + sentence would otherwise read as a caption to whichever select it |
| + happened to land beside. flex-basis and not display, so the hidden |
| + attribute still hides the row — see the note at the top of this file. */ |
| +.meet-raw-row { |
| + flex-basis: 100%; |
| +} |
| + |
| +.meet-raw-help { |
| + margin-block: var(--rst-sp-1) 0; |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + max-inline-size: 42rem; |
| +} |
| + |
| .meet-tile-name > span:first-child { |
| overflow: hidden; |
| text-overflow: ellipsis; |
| @@ -181,6 +197,18 @@ |
| margin-inline-start: var(--rst-sp-1); |
| } |
| +/* The device panel's own live region (a camera that would not open, a |
| + microphone another app is holding). Never colour alone: the sentence |
| + is the whole signal and the weight is a second cue on top of it. No |
| + display of its own, so the hidden attribute still hides it — see the |
| + note at the top of this file. */ |
| +.meet-device-note { |
| + color: var(--rst-text); |
| + font-weight: 600; |
| + flex-basis: 100%; |
| + margin-block: var(--rst-sp-2) 0; |
| +} |
| + |
| /* The relay warning (spec §5.3 step 1) sits under the status line in a |
| live region of its own. Never colour alone: the sentence is the whole |
| signal and the weight is a second cue on top of it. No display of its |
| @@ -226,6 +254,21 @@ |
| .meet-pickers { margin-block-start: 0; } |
| .meet-pickers > div { flex: 1 1 12rem; min-inline-size: 0; } |
| .meet-pickers select { max-inline-size: 100%; } |
| +/* The rule above makes every picker a ~12rem flex item, which is right |
| + for the three selects and wrong for the studio switch: it carries a |
| + sentence of explanation, and squeezed into a select's column that |
| + sentence sets in a ribbon. More specific than the line above rather |
| + than ordered after it, so it survives the two being reordered. */ |
| +.meet-pickers > .meet-raw-row { flex: 1 1 100%; } |
| +/* The room's device panel takes a row of its own under the controls |
| + that opened it. Spanning both columns keeps it out of the sidebar, |
| + where it would argue with chat over a 20rem column; grid-row 3 puts |
| + it under the controls (row 2) rather than letting auto-placement |
| + drop it beside the stage. */ |
| +#call-workspace #room-devices { grid-column: 1 / -1; } |
| +@media (min-width: 64rem) { |
| + #call-workspace #room-devices { grid-row: 3; } |
| +} |
| .meet-queue li { flex-wrap: wrap; overflow-wrap: anywhere; } |
| @media (min-width: 64rem) { |
| .meet-device-layout { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); } |
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js| index 234d5f8..71e11b8 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -67,6 +67,21 @@ |
| // 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'); |
| + // The device panel and its controls. Every one of these is optional |
| + // as far as this file is concerned — the room renders without them in |
| + // a browser that cannot enumerate devices, and switchDevice and |
| + // fillDevices both check before they reach for one. |
| + const devicesToggle = $('devices-toggle'); |
| + const devicesPanel = $('room-devices'); |
| + const deviceNote = $('device-note'); |
| + const pickCamera = $('pick-camera'); |
| + const pickMic = $('pick-mic'); |
| + const pickSpeaker = $('pick-speaker'); |
| + const cameraRow = $('camera-row'); |
| + const micRow = $('mic-row'); |
| + const speakerRow = $('speaker-row'); |
| + const rawRow = $('raw-row'); |
| + const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null; |
| const DEVICE_KEY = 'meet.devices'; |
| const BACKOFF_MIN = 500; |
| @@ -180,6 +195,12 @@ |
| // Reading any advance at all as health let such a tile reset the |
| // budget below for ever and never earn its reset. |
| const RENDER_MOVED = 0.25; |
| + // The ceiling studio audio asks for, in bits per second. Opus is |
| + // transparent for speech well below it; the number is picked so a |
| + // microphone someone turned the processing off for is not then |
| + // squeezed down a pipe built for speech. It is a ceiling and not a |
| + // floor — the encoder still spends only what the link will carry. |
| + const RAW_AUDIO_BITRATE = 128000; |
| // §5.4: the tile key a peer's screen gets, built by shareKey. |
| const SHARE_SUFFIX = '#share'; |
| const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' && |
| @@ -315,6 +336,96 @@ |
| // Local media (§3.3's stored choices, §5.1's tracks) |
| // --------------------------------------------------------------- |
| + // audioConstraints builds the microphone half of a getUserMedia |
| + // request out of the stored preferences. It is a pure function of |
| + // `prefs` so the gate can lift it out of both files and check the two |
| + // copies still agree (test/browser/audio.test.mjs). room.js carries |
| + // it verbatim, because the room asks for a stream of its own at join |
| + // — the holding screen's is stopped by then — and a choice that held |
| + // on one page and not the other would be worse than no choice. |
| + // |
| + // The three flags are the browser's voice pipeline: echo |
| + // cancellation, noise suppression and automatic gain. Every browser |
| + // turns all three on for a microphone unless told otherwise, which is |
| + // right for a laptop on a kitchen table and wrong for an interface in |
| + // a room someone has treated — gain riding a held note, noise |
| + // suppression taking a cello for a fan. `rawAudio` is the person |
| + // saying which of those two rooms they are in. |
| + // |
| + // They are asked for as ideals and never as `exact`. A browser that |
| + // cannot turn its processing off should hand over a processed |
| + // microphone, not refuse getUserMedia and cost the person their |
| + // voice — the same rule the device ladder is built on. It is also why |
| + // the holding screen checks afterwards what it was actually given |
| + // (rawHonoured) instead of trusting that it got what it asked for. |
| + // |
| + // voiceIsolation is the newer, heavier filter in Safari and Chrome, |
| + // and it is not everywhere. An unknown constraint name in this basic, |
| + // non-exact form is dropped rather than refused, so naming it costs |
| + // nothing in a browser that has never heard of it. |
| + function audioConstraints(prefs) { |
| + const processed = !prefs.rawAudio; |
| + const constraints = { |
| + echoCancellation: processed, |
| + noiseSuppression: processed, |
| + autoGainControl: processed, |
| + voiceIsolation: processed, |
| + }; |
| + if (prefs.mic) constraints.deviceId = { exact: prefs.mic }; |
| + return constraints; |
| + } |
| + |
| + // tuneAudioSender raises the audio ceiling on one connection, and is |
| + // the other half of what studio audio promises: turning the filters |
| + // off buys nothing if the result then goes out through Opus at its |
| + // default of roughly 32 kbit/s mono, which is a codec a good |
| + // microphone is heard *through* rather than heard. |
| + // |
| + // It is a sender-side change, so it needs no SDP of our own and no |
| + // renegotiation, and §5.1's perfect negotiation above never learns it |
| + // happened. Stereo is deliberately not attempted: it lives in the |
| + // fmtp line, and reaching it would mean building offers by hand |
| + // instead of the argumentless setLocalDescription §5.1 is written on |
| + // — a large risk to the one part of this file that must not wobble, |
| + // bought for a microphone that is almost always mono anyway. |
| + // |
| + // Every step is optional in some browser that is otherwise fine, so |
| + // the whole thing is best effort: a browser that will not take the |
| + // parameters carries the call at its own bitrate rather than losing |
| + // the connection over it. The encodings array is read right after |
| + // addTrack, which is where Chromium has already built it; a browser |
| + // that fills it in later simply keeps its default here, and that is a |
| + // quieter microphone and not a broken one. |
| + function tuneAudioSender(pc) { |
| + let sender; |
| + try { |
| + sender = pc.getSenders().find((s) => s.track && s.track.kind === 'audio'); |
| + } catch { |
| + return; |
| + } |
| + if (!sender || typeof sender.getParameters !== 'function') return; |
| + let params; |
| + try { |
| + params = sender.getParameters(); |
| + } catch { |
| + return; |
| + } |
| + // A sender the browser has not built encodings for yet is not one to |
| + // invent an encodings array for: that is a different request, and |
| + // not the one this function is making. |
| + if (!params.encodings || !params.encodings.length) return; |
| + // Both directions. Studio audio turned off mid-call has to give the |
| + // ceiling back, and `undefined` — not zero, which is a request to |
| + // send nothing — is how the API spells "the encoder's own default". |
| + if (prefs.rawAudio) params.encodings[0].maxBitrate = RAW_AUDIO_BITRATE; |
| + else delete params.encodings[0].maxBitrate; |
| + Promise.resolve().then(() => sender.setParameters(params)).catch(() => { |
| + // Chromium refuses parameters that did not come from the most |
| + // recent getParameters, which a renegotiation in flight can cause. |
| + // The call is unharmed, and the next connection gets its own try. |
| + }); |
| + } |
| + |
| // startLocal walks down a ladder: the remembered devices, then the |
| // browser's defaults, then audio alone. A camera that is missing or |
| // in use should not cost the call its microphone as well, and a |
| @@ -327,10 +438,16 @@ |
| } |
| const exact = (id) => ({ deviceId: { exact: id } }); |
| const remembered = !!(prefs.mic || prefs.camera); |
| + // The processing choice rides every rung. Falling back to the |
| + // browser's default *device* is not a reason to put the voice |
| + // pipeline back on someone who asked for it off, so only the |
| + // remembered device id is dropped on the way down — by |
| + // forgetDevices, which touches mic and camera and never rawAudio. |
| + const raw = { rawAudio: prefs.rawAudio }; |
| const attempts = [ |
| - { audio: prefs.mic ? exact(prefs.mic) : true, video: prefs.camera ? exact(prefs.camera) : true }, |
| - { audio: true, video: true }, |
| - { audio: true, video: false }, |
| + { audio: audioConstraints(prefs), video: prefs.camera ? exact(prefs.camera) : true }, |
| + { audio: audioConstraints(raw), video: true }, |
| + { audio: audioConstraints(raw), video: false }, |
| ]; |
| for (let i = 0; i < attempts.length; i++) { |
| try { |
| @@ -385,6 +502,323 @@ |
| local = null; |
| } |
| + // --------------------------------------------------------------- |
| + // Changing a device mid-call (§3.3's stored choice, made movable) |
| + // --------------------------------------------------------------- |
| + // |
| + // The holding screen picks the devices; until now that was the only |
| + // place they could be picked, and a microphone chosen wrongly — or |
| + // unplugged, or wanted for a headset halfway through — cost the |
| + // person the call to change. Everything below is that choice, applied |
| + // to a call already running. |
| + // |
| + // The whole thing rests on replaceTrack, which swaps the media a |
| + // sender is sending without touching the m-line it sends on. That is |
| + // the part §5.1 never has to hear about: no offer, no answer, no |
| + // rollback, and no chance of the glare the negotiation above spends |
| + // 300 ms avoiding. The one case that does renegotiate is named at |
| + // sendTrack, and it is a direction change on an m-line that already |
| + // exists rather than a new one. |
| + |
| + // Two clicks on a picker are two switches, and they must not run at |
| + // once. devices.js can settle that with a generation counter alone |
| + // because the only thing it is racing for is one <video>; here the |
| + // loser would be racing for what every sender in the mesh is holding, |
| + // and interleaved replaceTracks leave that decided by whichever |
| + // getUserMedia happened to settle last. |
| + // |
| + // So they are serialised, and the counter only decides which queued |
| + // run is still wanted: three clicks queue three runs, the first two |
| + // find themselves superseded before their turn and return without |
| + // opening anything, and the third does the work. One switch is in |
| + // flight at a time and the last click wins, which is what clicking |
| + // means. |
| + // One counter per kind, not one between them: a camera and a |
| + // microphone are two independent choices, and a shared counter made |
| + // changing the camera silently cancel a microphone change whose |
| + // getUserMedia had not settled — the select snapping back with |
| + // nothing said about why. |
| + const switching = { audio: 0, video: 0 }; |
| + let switchQueue = Promise.resolve(); |
| + |
| + // senderFor finds the transceiver carrying one kind for this call — |
| + // the camera or the microphone, never the screen. entry.shareTx is |
| + // video too, and replacing *its* track with a camera would put the |
| + // camera on the m-line the far side has already labelled a screen |
| + // (§5.4), which is the one mistake here that would look like a bug in |
| + // someone else's browser. |
| + function senderFor(entry, kind) { |
| + let transceivers; |
| + try { |
| + transceivers = entry.pc.getTransceivers(); |
| + } catch { |
| + return null; |
| + } |
| + for (const tx of transceivers) { |
| + if (tx === entry.shareTx || tx.stopped) continue; |
| + // receiver.track is present on every transceiver and carries the |
| + // kind even when nothing is being sent yet, which is exactly the |
| + // recvonly case this has to find. |
| + const known = (tx.sender && tx.sender.track) || (tx.receiver && tx.receiver.track); |
| + if (known && known.kind === kind) return tx; |
| + } |
| + return null; |
| + } |
| + |
| + // sendTrack puts one track on one connection. |
| + // |
| + // The replaceTrack is the whole job when this browser was already |
| + // sending that kind. When it was not — it came down startLocal's |
| + // ladder with no camera, or joined with nothing at all — createPC |
| + // left a `recvonly` transceiver for the kind, and that m-line is |
| + // already in the right place in the order. Turning it round is a |
| + // direction change on an existing m-line, so no m-line is added and |
| + // none moves: the hazard addShareTo carries (an unassociated |
| + // transceiver claiming the m-line a remote offer meant for something |
| + // else) is not in this path at all. It still waits for `stable`, |
| + // because a direction changed mid-negotiation is a change the offer |
| + // in flight does not carry, and the deferred ones are picked up by |
| + // the connection's own signalingstatechange. |
| + async function sendTrack(entry, kind, track) { |
| + const tx = senderFor(entry, kind); |
| + if (!tx || !tx.sender || typeof tx.sender.replaceTrack !== 'function') return; |
| + try { |
| + await tx.sender.replaceTrack(track); |
| + } catch { |
| + // A connection closing under us, or a browser refusing the track. |
| + // Every other peer still gets it, and this one is the reset path's |
| + // problem rather than this function's. |
| + return; |
| + } |
| + // classifyTrack reads entry.sentVideo to tell an arriving camera |
| + // from an arriving screen, and a browser that starts sending video |
| + // for the first time here has just changed that answer. |
| + if (kind === 'video') entry.sentVideo = true; |
| + if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') return; |
| + if (entry.pc.signalingState !== 'stable') { |
| + entry.upgrades.add(kind); |
| + return; |
| + } |
| + try { |
| + tx.direction = 'sendrecv'; |
| + } catch { |
| + // Older browsers make direction read-only. The track is on the |
| + // sender either way; it simply is not sent until the next full |
| + // renegotiation builds the m-line sending. |
| + } |
| + } |
| + |
| + // upgradeWhenQuiet runs the direction changes sendTrack could not make |
| + // because the connection was mid-negotiation. Called from the same |
| + // signalingstatechange that adds a deferred share transceiver. |
| + function upgradeWhenQuiet(entry) { |
| + if (!entry.upgrades.size || entry.pc.signalingState !== 'stable') return; |
| + for (const kind of Array.from(entry.upgrades)) { |
| + entry.upgrades.delete(kind); |
| + const tx = senderFor(entry, kind); |
| + if (!tx || !tx.sender || !tx.sender.track) continue; |
| + if (tx.direction === 'sendrecv' || tx.direction === 'sendonly') continue; |
| + try { |
| + tx.direction = 'sendrecv'; |
| + } catch { |
| + // As above: nothing to do about it, and nothing broken by it. |
| + } |
| + } |
| + } |
| + |
| + // switchDevice is the gesture: one kind, one device id, applied to |
| + // every connection and then to the local stream. |
| + // |
| + // Order matters twice here. The new track is disabled before it is |
| + // anywhere near a sender, because a track that goes live for even one |
| + // frame while the person is muted has broken the only promise the |
| + // mute button makes. And the old track is stopped only once the new |
| + // one is on every sender: stopping it first would take the camera |
| + // light off a beat early and send black frames in the gap. |
| + function switchDevice(kind, deviceId) { |
| + const myGen = ++switching[kind]; |
| + const run = switchQueue.then(() => applySwitch(kind, deviceId, myGen)); |
| + // The queue must not break on a failure, or one rejected switch |
| + // would swallow every switch after it. The caller still sees the |
| + // rejection through `run`. |
| + switchQueue = run.catch(() => {}); |
| + return run; |
| + } |
| + |
| + // Returns true when the call is actually on the new device, so a |
| + // caller that changed a preference to ask for the switch can put it |
| + // back when the switch did not happen. |
| + async function applySwitch(kind, deviceId, myGen) { |
| + // Superseded before this run's turn came round, or the call ended |
| + // while it waited. Nothing has been opened yet, so there is nothing |
| + // to put back. |
| + if (myGen !== switching[kind] || state.stopped) return false; |
| + const wanted = kind === 'audio' |
| + ? { audio: audioConstraints(Object.assign({}, prefs, { mic: deviceId })) } |
| + : { video: deviceId ? { deviceId: { exact: deviceId } } : true }; |
| + let fresh; |
| + try { |
| + fresh = await navigator.mediaDevices.getUserMedia(wanted); |
| + } catch (err) { |
| + if (myGen !== switching[kind]) return false; |
| + note(deviceError(kind, err)); |
| + // The choice is not saved: the pickers are put back to the device |
| + // actually in use, so the select never claims a device the call is |
| + // not on. |
| + fillDevices().catch(() => {}); |
| + return false; |
| + } |
| + const track = fresh.getTracks()[0]; |
| + if (myGen !== switching[kind] || !track || state.stopped) { |
| + for (const t of fresh.getTracks()) t.stop(); |
| + return false; |
| + } |
| + track.enabled = kind === 'audio' ? !prefs.muted : !prefs.cameraOff; |
| + for (const entry of pcs.values()) { |
| + // Sequential and not Promise.all: replaceTrack is cheap, the mesh |
| + // is four, and a failure on one connection should not abandon the |
| + // rest through a rejected race. |
| + await sendTrack(entry, kind, track); |
| + } |
| + // No *generation* check here, and that is deliberate. From the loop |
| + // above onwards this run owns what every sender is holding, so |
| + // stopping the track to defer to a newer choice would stop the |
| + // media the call is sending — until that newer choice arrives, which |
| + // it may never do if its own getUserMedia fails, leaving every peer |
| + // watching a track that ended. The queued run replaces the senders |
| + // and `local` together when its turn comes; until then this is the |
| + // truth and is recorded as such. |
| + // |
| + // The call *ending* is the other thing entirely, and does need |
| + // checking: terminal() leaves the page alive, so without this the |
| + // line below would build a fresh MediaStream around a camera that |
| + // was opened for a call that is already over, and nothing would |
| + // ever stop it. A light left on after the meeting ended is the one |
| + // way this file can be rude. |
| + if (state.stopped) { |
| + track.stop(); |
| + return false; |
| + } |
| + if (!local) local = new MediaStream(); |
| + for (const old of kind === 'audio' ? local.getAudioTracks() : local.getVideoTracks()) { |
| + local.removeTrack(old); |
| + old.stop(); |
| + } |
| + local.addTrack(track); |
| + // A track that ends on its own — the device unplugged mid-call — |
| + // should leave the pickers telling the truth about what is left. |
| + track.addEventListener('ended', () => { |
| + fillDevices().catch(() => {}); |
| + }); |
| + // An id that names nothing is the browser's default, and the |
| + // absence of a remembered device is how the rest of this file and |
| + // devices.js both spell that. |
| + const key = kind === 'audio' ? 'mic' : 'camera'; |
| + if (deviceId) prefs[key] = deviceId; |
| + else delete prefs[key]; |
| + savePrefs(); |
| + note(''); |
| + applyToggles(); |
| + renderTiles(); |
| + // The audio ceiling follows the choice and not the connection: a |
| + // studio switch flipped mid-call has to reach the connections that |
| + // already exist, and flipped back has to let them go again. |
| + // tuneAudioSender reads the preference itself, so this is the same |
| + // call in both directions. |
| + if (kind === 'audio') { |
| + for (const entry of pcs.values()) tuneAudioSender(entry.pc); |
| + } |
| + await fillDevices().catch(() => {}); |
| + return true; |
| + } |
| + |
| + // deviceError is the sentence for a device that would not open. The |
| + // names are getUserMedia's own; the wording says what the person can |
| + // do about it, and never that the call is over, because it is not — |
| + // the old device is still on every sender. |
| + function deviceError(kind, err) { |
| + const thing = kind === 'audio' ? 'microphone' : 'camera'; |
| + if (err && err.name === 'NotAllowedError') { |
| + return 'Meet needs permission to use that ' + thing + '. The call is still on the previous one.'; |
| + } |
| + if (err && err.name === 'NotReadableError') { |
| + return 'That ' + thing + ' is in use by another app. The call is still on the previous one.'; |
| + } |
| + return 'That ' + thing + ' could not be opened. The call is still on the previous one.'; |
| + } |
| + |
| + // note writes the device panel's own live region. It is not `say`: |
| + // renderStatus owns #status and rewrites it on every render, so a |
| + // sentence put there would be gone by the next roster. |
| + function note(text) { |
| + if (!deviceNote) return; |
| + deviceNote.textContent = text; |
| + deviceNote.hidden = !text; |
| + } |
| + |
| + // fillDevices is devices.js's fillPickers, for the room. The two are |
| + // deliberate mirrors — same ids, same rules, same reason for each — |
| + // and test/browser/devices.test.mjs holds them to it. Labels only |
| + // exist once permission has been granted, which in the room it always |
| + // has by the time this runs. |
| + async function fillDevices() { |
| + if (!pickCamera || !navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return; |
| + let devices; |
| + try { |
| + devices = await navigator.mediaDevices.enumerateDevices(); |
| + } catch { |
| + devices = []; |
| + } |
| + const fillOne = (select, kind, chosen) => { |
| + select.replaceChildren(); |
| + let count = 0; |
| + for (const device of devices) { |
| + if (device.kind !== kind) continue; |
| + count++; |
| + const option = document.createElement('option'); |
| + option.value = device.deviceId; |
| + option.textContent = device.label || labelFor(kind, count); |
| + if (device.deviceId === chosen) option.selected = true; |
| + select.append(option); |
| + } |
| + return count; |
| + }; |
| + const cameraCount = fillOne(pickCamera, 'videoinput', liveDeviceId('video') || prefs.camera); |
| + const micCount = fillOne(pickMic, 'audioinput', liveDeviceId('audio') || prefs.mic); |
| + const speakerCount = HAS_SINK_ID ? fillOne(pickSpeaker, 'audiooutput', prefs.speaker) : 0; |
| + // A field with nothing to choose from is clutter, not help. Unlike |
| + // the holding screen, the camera and microphone rows can be empty |
| + // here too — a call joined with neither is a call this file is |
| + // built to allow. |
| + if (cameraRow) cameraRow.hidden = cameraCount === 0; |
| + if (micRow) micRow.hidden = micCount === 0; |
| + if (speakerRow) speakerRow.hidden = !HAS_SINK_ID || speakerCount === 0; |
| + if (rawRow) rawRow.hidden = micCount === 0; |
| + } |
| + |
| + function labelFor(kind, count) { |
| + return (kind === 'videoinput' ? 'Camera ' : 'Microphone ') + count; |
| + } |
| + |
| + // liveDeviceId is what the call is *actually* on, which is the honest |
| + // thing for a select to be showing: prefs can name a device that |
| + // startLocal's ladder never managed to open. |
| + function liveDeviceId(kind) { |
| + const track = local && local.getTracks().find((t) => t.kind === kind && t.readyState === 'live'); |
| + if (!track || typeof track.getSettings !== 'function') return ''; |
| + return track.getSettings().deviceId || ''; |
| + } |
| + |
| + // applySinkAll re-routes every remote tile after a speaker change. |
| + // applySink does one element and is what a new tile gets; this is the |
| + // same thing for the tiles that already exist. |
| + function applySinkAll() { |
| + for (const [key, t] of tiles) { |
| + if (t.self || key === 'self') continue; |
| + applySink(t.video); |
| + } |
| + } |
| + |
| // --------------------------------------------------------------- |
| // The stream (§4.1, §4.5) |
| // --------------------------------------------------------------- |
| @@ -1106,6 +1540,9 @@ |
| // m-line it built for nobody. classifyTrack's direction rule turns |
| // on which of the two is true. |
| sentVideo: false, |
| + // Kinds whose transceiver switchDevice turned round while the |
| + // connection was mid-negotiation, waiting for it to settle. |
| + upgrades: new Set(), |
| sharing: false, |
| share: null, |
| // The mid the peer's screen arrives on, so the getStats fallback |
| @@ -1164,6 +1601,7 @@ |
| // rollback — and then the screen is being sent on the m-line the |
| // far side means for something else. |
| addShareTo(entry); |
| + upgradeWhenQuiet(entry); |
| adoptWhenQuiet(id, entry); |
| // And it is when an ask the negotiation rounds ran out on gets |
| // its turn (Codex S4-8): a connection that was mid-negotiation is |
| @@ -1195,6 +1633,7 @@ |
| // here, or a device that went away — is not worth the connection. |
| // The recvonly transceivers below cover what it would have been. |
| } |
| + tuneAudioSender(pc); |
| for (const kind of ['video', 'audio']) { |
| if (hasLiveTrack(kind)) continue; |
| try { |
| @@ -3517,6 +3956,14 @@ |
| // enabled — the same rule the two device toggles follow when there |
| // is no track for them to move. |
| if (shareButton) shareButton.disabled = !on || !HAS_DISPLAY_MEDIA; |
| + // The device panel goes the same way. A terminal screen's pickers |
| + // are inert anyway — applySwitch refuses once state.stopped — but a |
| + // live-looking select that silently does nothing is the worse half |
| + // of that, so they are disabled and say so. |
| + if (devicesToggle) devicesToggle.disabled = !on; |
| + for (const el of [pickCamera, pickMic, pickSpeaker, rawInput]) { |
| + if (el) el.disabled = !on; |
| + } |
| } |
| // --------------------------------------------------------------- |
| @@ -3857,6 +4304,98 @@ |
| applyToggles(); |
| }); |
| + // The device panel. The button is hidden by the server and revealed |
| + // only where there is something behind it: a browser with no |
| + // enumerateDevices has no list to draw and no switch to offer, and a |
| + // control that does nothing is worse than no control. |
| + if (devicesToggle && devicesPanel && navigator.mediaDevices && |
| + navigator.mediaDevices.enumerateDevices) { |
| + devicesToggle.hidden = false; |
| + devicesToggle.addEventListener('click', () => { |
| + const open = devicesPanel.hidden; |
| + devicesPanel.hidden = !open; |
| + devicesToggle.setAttribute('aria-expanded', String(open)); |
| + // Filled on opening rather than at load: the list is only worth |
| + // having when it is about to be looked at, and a device plugged |
| + // in during the call is then always in the one that is shown. |
| + if (open) { |
| + fillDevices().catch((err) => { |
| + console.error('meet: devices', err); |
| + }); |
| + if (pickCamera && cameraRow && !cameraRow.hidden) pickCamera.focus(); |
| + } |
| + }); |
| + if (rawInput) { |
| + if (deviceNote || $('raw-help')) rawInput.setAttribute('aria-describedby', 'raw-help'); |
| + rawInput.checked = !!prefs.rawAudio; |
| + } |
| + navigator.mediaDevices.addEventListener?.('devicechange', () => { |
| + if (!devicesPanel.hidden) fillDevices().catch(() => {}); |
| + }); |
| + // Filled once now, as well as on every open. Opening reveals the |
| + // panel synchronously and fills it a microtask later, so without |
| + // this the first open is a flash of three empty selects — and the |
| + // focus below would be deciding on rows whose hidden state nothing |
| + // had set yet. |
| + fillDevices().catch(() => {}); |
| + } |
| + |
| + // The three pickers and the studio switch. Each one is a getUserMedia |
| + // and a replaceTrack per connection (switchDevice), except the |
| + // speaker, which never touches the call at all: it is where this |
| + // browser plays what it has already received. |
| + if (pickCamera) { |
| + pickCamera.addEventListener('change', () => { |
| + switchDevice('video', pickCamera.value).catch((err) => { |
| + console.error('meet: camera', err); |
| + }); |
| + }); |
| + } |
| + if (pickMic) { |
| + pickMic.addEventListener('change', () => { |
| + switchDevice('audio', pickMic.value).catch((err) => { |
| + console.error('meet: microphone', err); |
| + }); |
| + }); |
| + } |
| + if (pickSpeaker) { |
| + pickSpeaker.addEventListener('change', () => { |
| + prefs.speaker = pickSpeaker.value; |
| + savePrefs(); |
| + applySinkAll(); |
| + }); |
| + } |
| + if (rawInput) { |
| + // Studio audio is chosen when the track is opened and cannot be |
| + // changed on a track already running, so this re-opens the |
| + // microphone that is already in use — the same switch as picking it |
| + // again from the list, with a different set of constraints behind |
| + // it. |
| + rawInput.addEventListener('change', () => { |
| + const was = !!prefs.rawAudio; |
| + prefs.rawAudio = rawInput.checked; |
| + // The remembered microphone and not the live one. Passing the id |
| + // of whatever happens to be open would write it into prefs.mic |
| + // and pin someone who had been following the system default to |
| + // one device, for ever, because they touched an audio setting. |
| + switchDevice('audio', prefs.mic || '').then((ok) => { |
| + if (ok) { |
| + savePrefs(); |
| + return; |
| + } |
| + // The microphone would not re-open, so the call is still on the |
| + // processed track and saying otherwise — in the checkbox or in |
| + // localStorage — would be a lie the next call would inherit. |
| + prefs.rawAudio = was; |
| + rawInput.checked = was; |
| + }).catch((err) => { |
| + prefs.rawAudio = was; |
| + rawInput.checked = was; |
| + console.error('meet: studio audio', err); |
| + }); |
| + }); |
| + } |
| + |
| // §5.4's gesture. The handler is not async: a rejection escaping one |
| // goes where neither a person nor a gate could see it, so the work is |
| // a named function with a catch of its own — the same shape as the |
| @@ -4152,8 +4691,23 @@ |
| const sender = tx && tx.sender; |
| const track = sender && sender.track; |
| if (!sender || !track || typeof sender.replaceTrack !== 'function') return; |
| + // Which camera the call is on, captured before the awaits below. |
| + // Asking the sender afterwards would be the more obvious check and |
| + // is the weaker one: it reads a browser's own bookkeeping to infer |
| + // whether something else has been here, where this asks the |
| + // question directly. |
| + const generation = switching.video; |
| try { |
| await sender.replaceTrack(null); |
| + // A device switch that started in this gap owns this sender now, |
| + // and the track captured above is the one it is retiring — it |
| + // stops it. Putting it back would undo the switch and freeze this |
| + // peer on a track that has ended, which is a worse fault than the |
| + // stall being repaired. The switch sets its own track on every |
| + // sender, so leaving is safe; and if that switch then fails, the |
| + // stall detector is still watching and asks again on the next |
| + // poll. |
| + if (switching.video !== generation || track.readyState === 'ended') return; |
| await sender.replaceTrack(track); |
| } catch { |
| // A connection that is closing. The reset is behind this anyway. |
| @@ -4386,6 +4940,26 @@ |
| } |
| window.meet = { |
| + // local() is what this tab is actually sending: one entry per track |
| + // on the local stream, with its kind and readyState. It is the fact |
| + // a device switch has to be judged on and that the DOM cannot |
| + // answer — a self tile looks exactly the same whichever microphone |
| + // is behind it. |
| + // |
| + // No device id and no label. devices.js's promise is that neither |
| + // ever leaves this browser, and a test hook is not an exception |
| + // worth carving into it. Whether a device was *chosen* is a |
| + // boolean, which is all a gate needs to tell a switch from a |
| + // default. |
| + local() { |
| + if (!local) return []; |
| + return local.getTracks().map((t) => ({ |
| + kind: t.kind, |
| + state: t.readyState, |
| + enabled: !!t.enabled, |
| + chosen: !!(typeof t.getSettings === 'function' && t.getSettings().deviceId), |
| + })); |
| + }, |
| // media() is the identity reading the fix wave's second round asked |
| // for: which track each tile is bound to, which tracks its element |
| // is actually holding, and what the connection's own receivers say |
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html| index ed0e2e4..8575035 100644 |
| --- a/internal/meet/templates/join.html |
| +++ b/internal/meet/templates/join.html |
| @@ -19,6 +19,14 @@ |
| <div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div> |
| <div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div> |
| <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div> |
| + {{/* Studio audio: the unprocessed capture option. field-check is the |
| + vendored toggle (AGENTS.md's field rule), and the sentence under |
| + it is wired to the input with aria-describedby by devices.js, |
| + because field-check gives its checkbox no id to write here. The |
| + row starts hidden like the speaker row: devices.js reveals it |
| + once it knows there is a microphone to apply it to. */}} |
| + <div id="raw-row" class="meet-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}} |
| + <p id="raw-help" class="meet-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div> |
| </div> |
| <div class="meet-controls" estilo-call-actions> |
| <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html| index 6bc84be..ee1d504 100644 |
| --- a/internal/meet/templates/room.html |
| +++ b/internal/meet/templates/room.html |
| @@ -28,10 +28,37 @@ |
| <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="mute-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| <button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="camera-state" class="meet-toggle-state" aria-hidden="true">On</span></button> |
| <button type="button" id="share" rst-btn aria-pressed="false">{{icon "monitor"}}Share screen<span id="share-state" class="meet-toggle-state" aria-hidden="true">Off</span></button> |
| + <button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>{{icon "sliders-horizontal"}}Devices</button> |
| {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}} |
| <button type="button" id="toggle-chat" rst-btn aria-expanded="true" aria-controls="chat" hidden>{{icon "message-circle"}}Chat<span id="chat-unread" aria-hidden="true"></span></button> |
| <button type="button" id="leave" rst-btn="danger">{{icon "log-out"}}Leave</button> |
| </div> |
| + {{/* The device panel, closed until asked for. A call's own screen is |
| + the video and the controls; three selects permanently under them |
| + would be furniture the whole call pays for so that someone can |
| + change a microphone once. room.js reveals the button — the panel |
| + is useless in a browser that cannot enumerate devices — and fills |
| + the selects from the same enumeration the holding screen uses. |
| + The ids are devices.js's own on purpose: the two fill routines |
| + are mirrors, and a reader comparing them should not also have to |
| + translate names. |
| + |
| + It sits after the controls in the workspace, which is where |
| + meet.css gives it a row of its own under them: a disclosure |
| + belongs beneath the control that opened it, and putting it in |
| + the sidebar column would have it argue with chat for the space. */}} |
| + <div id="room-devices" class="meet-pickers" hidden> |
| + <div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div> |
| + <div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div> |
| + <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div> |
| + <div id="raw-row" class="meet-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}} |
| + <p id="raw-help" class="meet-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div> |
| + {{/* Device messages get a live region of their own, like the relay |
| + warning: renderStatus owns #status and rewrites it on every |
| + render, so a sentence about a camera that would not open has |
| + to live somewhere that is not about to be overwritten. */}} |
| + <p id="device-note" class="meet-device-note" role="status" hidden></p> |
| + </div> |
| {{template "chat-panel" .}} |
| </div> |
| <div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div> |
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go| index 3e64427..03755e0 100644 |
| --- a/internal/meettest/browser_test.go |
| +++ b/internal/meettest/browser_test.go |
| @@ -83,6 +83,7 @@ var scenarios = []string{ |
| "layout", |
| "devices", |
| "call", |
| + "switching", |
| "share", |
| "mutualshare", |
| "laterolls", |
diff --git a/internal/meettest/holding_test.go b/internal/meettest/holding_test.go| index cf3f17d..2ba52c0 100644 |
| --- a/internal/meettest/holding_test.go |
| +++ b/internal/meettest/holding_test.go |
| @@ -163,3 +163,43 @@ func TestHoldingPageIs404ForEndedMeetings(t *testing.T) { |
| t.Fatalf("ended: %d", rec.Code) |
| } |
| } |
| + |
| +// Studio audio is the unprocessed capture option (the microphone asked |
| +// for with echo cancellation, noise suppression and automatic gain all |
| +// off, and a higher Opus ceiling to send it through). Three facts about |
| +// the markup are worth holding here rather than in a browser, because |
| +// devices.js only ever moves the checkbox's `checked` and the row's |
| +// `hidden` and would go on "working" with any of them broken: |
| +// |
| +// - it is the vendored field-check and not a hand-rolled checkbox, |
| +// which AGENTS.md's field rule requires — rst-switch is that |
| +// partial's own attribute and nothing else in these pages emits it; |
| +// - the row starts hidden, like the speaker row, so a scriptless |
| +// browser is not shown a control that cannot do anything; |
| +// - the help sentence carries the id devices.js wires to the input |
| +// with aria-describedby, so a rename here that left the script |
| +// alone would silently unwire the explanation. |
| +func TestHoldingPageOffersStudioAudioAsAVendoredField(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.get("/m/" + sqid + "/join").Body.String() |
| + for _, want := range []string{ |
| + `<div id="raw-row" class="meet-raw-row" hidden>`, |
| + `<label rst-switch>`, |
| + `name="raw-audio"`, |
| + `id="raw-help"`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("holding page lacks %q for studio audio: %s", want, body) |
| + } |
| + } |
| + // The sentence has to say what is being turned off. "Studio audio" |
| + // alone is a name, not an explanation, and someone reaching for it |
| + // on a laptop should be told why not before they hear the echo. |
| + for _, want := range []string{"echo cancellation", "noise suppression", "automatic gain"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("studio audio's help sentence does not mention %q: %s", want, body) |
| + } |
| + } |
| +} |
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go| index e2cc078..08faa4f 100644 |
| --- a/internal/meettest/room_test.go |
| +++ b/internal/meettest/room_test.go |
| @@ -602,3 +602,50 @@ func TestNativeRoomInfoUsesRoomAuthority(t *testing.T) { |
| t.Fatal("metadata leaked another meeting") |
| } |
| } |
| + |
| +// The room's device panel (mid-call switching). The server renders it |
| +// closed and renders the button that opens it hidden: room.js reveals |
| +// the button only in a browser that can enumerate devices, so a |
| +// scriptless page — and a browser with no enumeration — shows neither a |
| +// panel nor a control that would do nothing. The panel reuses the |
| +// holding screen's ids on purpose, because the two fill routines are |
| +// mirrors of each other and test/browser/devices.test.mjs holds them to |
| +// it; asserting them here is what stops a rename on one page from |
| +// quietly unwiring the other. |
| +func TestRoomRendersItsDevicePanelClosed(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.get("/m/" + sqid).Body.String() |
| + for _, want := range []string{ |
| + `<button type="button" id="devices-toggle" rst-btn aria-expanded="false" aria-controls="room-devices" hidden>`, |
| + `<div id="room-devices" class="meet-pickers" hidden>`, |
| + `id="pick-camera"`, |
| + `id="pick-mic"`, |
| + `id="pick-speaker"`, |
| + `id="device-note"`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("room page lacks %q for the device panel: %s", want, body) |
| + } |
| + } |
| +} |
| + |
| +// The device panel's own live region is not #status. renderStatus owns |
| +// #status and rewrites it on every roster, so a sentence about a camera |
| +// that would not open would be gone within the tick — which is exactly |
| +// the moment someone is reading it. |
| +func TestRoomDeviceNoteIsNotTheStatusLine(t *testing.T) { |
| + x, sqid, _ := memberWithMeeting(t) |
| + c := x.browser(t) |
| + c.signIn("paul@example.com") |
| + body := c.get("/m/" + sqid).Body.String() |
| + note := strings.Index(body, `id="device-note"`) |
| + status := strings.Index(body, `id="status"`) |
| + if note < 0 || status < 0 { |
| + t.Fatalf("room page is missing one of the two regions: note=%d status=%d", note, status) |
| + } |
| + if note == status { |
| + t.Fatal("the device note and the status line are the same element") |
| + } |
| +} |
diff --git a/test/browser/audio.test.mjs b/test/browser/audio.test.mjs| new file mode 100644 |
| index 0000000..fe83d1e |
| --- /dev/null |
| +++ b/test/browser/audio.test.mjs |
| @@ -0,0 +1,130 @@ |
| +// audio.test.mjs — the studio audio choice, gated without a browser. |
| +// |
| +// node --test test/browser/audio.test.mjs |
| +// |
| +// `audioConstraints` decides what the microphone is asked for, and it |
| +// is written twice on purpose: the holding screen opens a stream and |
| +// stops it, and the room opens its own at join, so devices.js and |
| +// room.js each carry a copy. Duplication between those two files is |
| +// this app's existing shape (forgetDevices is the same arrangement), |
| +// and the cost of it is drift — a choice that holds on the holding |
| +// screen, is silently dropped at join, and leaves someone listening to |
| +// noise suppression they turned off one page earlier. |
| +// |
| +// So the first test here is not about constraints at all. It is that |
| +// the two copies are still the same source. The rest is lifted out of |
| +// devices.js and run, the way negotiation.test.mjs runs offerCollision, |
| +// so what is under test is the shipped function and not a copy that can |
| +// go stale quietly. |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url)); |
| +const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url)); |
| + |
| +// lift returns the source of one `function name(...) {...}` by matching |
| +// braces from its declaration — negotiation.test.mjs's helper, and it |
| +// fails loudly on a rename rather than passing on a stale duplicate. |
| +function lift(source, name, where) { |
| + let start = source.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing'); |
| + if (source.slice(start - 6, start) === 'async ') start -= 6; |
| + 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 ' + where); |
| +} |
| + |
| +const devicesSrc = readFileSync(devicesJS, 'utf8'); |
| +const roomSrc = readFileSync(roomJS, 'utf8'); |
| + |
| +const fromDevices = lift(devicesSrc, 'audioConstraints', 'devices.js'); |
| +const fromRoom = lift(roomSrc, 'audioConstraints', 'room.js'); |
| + |
| +test('devices.js and room.js still ask for the microphone the same way', () => { |
| + // If this fails, one of the two was edited and the other was not. Copy |
| + // the change across rather than relaxing the assertion: the point of |
| + // the pair is that the holding screen's choice survives the join. |
| + assert.equal(fromDevices, fromRoom); |
| +}); |
| + |
| +// eslint-disable-next-line no-new-func |
| +const audioConstraints = new Function(fromDevices + '; return audioConstraints;')(); |
| + |
| +test('the voice pipeline is on unless it is turned off', () => { |
| + const c = audioConstraints({}); |
| + assert.equal(c.echoCancellation, true); |
| + assert.equal(c.noiseSuppression, true); |
| + assert.equal(c.autoGainControl, true); |
| + assert.equal(c.voiceIsolation, true); |
| +}); |
| + |
| +test('studio audio turns all of it off', () => { |
| + const c = audioConstraints({ rawAudio: true }); |
| + assert.equal(c.echoCancellation, false); |
| + assert.equal(c.noiseSuppression, false); |
| + assert.equal(c.autoGainControl, false); |
| + assert.equal(c.voiceIsolation, false); |
| +}); |
| + |
| +test('the processing is asked for as an ideal and never as exact', () => { |
| + // The safety property, and the reason rawHonoured exists. `exact` here |
| + // would mean a browser that cannot turn its filters off refuses |
| + // getUserMedia outright — costing someone their microphone over a |
| + // preference, which is the opposite of what the ladder in either file |
| + // is built to do. Booleans are ideals; objects are not. |
| + for (const prefs of [{}, { rawAudio: true }, { rawAudio: false }]) { |
| + const c = audioConstraints(prefs); |
| + for (const key of ['echoCancellation', 'noiseSuppression', 'autoGainControl', 'voiceIsolation']) { |
| + assert.equal(typeof c[key], 'boolean', key + ' must be a bare boolean, not ' + JSON.stringify(c[key])); |
| + } |
| + } |
| +}); |
| + |
| +test('a remembered microphone is still asked for exactly', () => { |
| + // The device id is the one thing that *is* exact: a remembered device |
| + // that has gone should fail so the caller can notice and forget it, |
| + // which is what disambiguate and forgetDevices are for. |
| + const c = audioConstraints({ mic: 'mic-1' }); |
| + assert.deepEqual(c.deviceId, { exact: 'mic-1' }); |
| +}); |
| + |
| +test('no remembered microphone names no device at all', () => { |
| + assert.equal('deviceId' in audioConstraints({}), false); |
| +}); |
| + |
| +test('studio audio and a remembered microphone are independent', () => { |
| + const c = audioConstraints({ mic: 'mic-1', rawAudio: true }); |
| + assert.deepEqual(c.deviceId, { exact: 'mic-1' }); |
| + assert.equal(c.noiseSuppression, false); |
| +}); |
| + |
| +test('the room lifts the audio ceiling, and only for studio audio', () => { |
| + // Turning the filters off buys nothing if Opus then runs at its |
| + // default of roughly 32 kbit/s, so the two ship together. The number |
| + // is read out of room.js rather than restated here. |
| + const m = /const RAW_AUDIO_BITRATE = (\d+);/.exec(roomSrc); |
| + assert.notEqual(m, null, 'RAW_AUDIO_BITRATE is not a number constant in room.js any more'); |
| + assert.ok(Number(m[1]) >= 64000, 'a ceiling below 64 kbit/s is not worth the switch'); |
| + |
| + // The ceiling follows the preference, not the connection. tuneAudioSender |
| + // runs for every connection and decides inside itself, so studio audio |
| + // flipped mid-call reaches the connections that already exist — and |
| + // flipped back lets them go again. Gating the *call* on prefs.rawAudio |
| + // was the half-feature: it only ever raised, and only on connections |
| + // built after the switch. |
| + const tune = /function tuneAudioSender\(pc\) \{[\s\S]*?\n \}/.exec(roomSrc); |
| + assert.notEqual(tune, null, 'tuneAudioSender is not in room.js any more'); |
| + assert.match(tune[0], /if \(prefs\.rawAudio\)/, 'tuneAudioSender must read the preference itself'); |
| + assert.match(tune[0], /delete params\.encodings\[0\]\.maxBitrate/, |
| + 'tuneAudioSender must give the ceiling back when studio audio goes off'); |
| + assert.match(roomSrc, /for \(const entry of pcs\.values\(\)\) tuneAudioSender\(entry\.pc\);/, |
| + 'an audio switch must re-tune the connections that already exist'); |
| +}); |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 6b3143b..376e8c1 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -1193,6 +1193,113 @@ const run = { |
| 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. |
| + // |
| + // Picking the same device again is the point rather than a weakness |
| + // of the test. What is under test is the swap — getUserMedia, a |
| + // replaceTrack on every connection's sender, the local stream rebuilt |
| + // under the self tile — and none of that cares whether the id it is |
| + // handed differs from the one already in use. Chromium's fake capture |
| + // offers one camera and one microphone, so a "different" device here |
| + // would be a second fake and not a second assertion. |
| + // |
| + // The assertion that matters is the negative one: a swap must not |
| + // cost the call anything. replaceTrack does not renegotiate, so a |
| + // regression that reached for addTrack, or that stopped the old track |
| + // before the new one was on the senders, shows up here as the far |
| + // side's frames stopping. |
| + async switching() { |
| + const ctxA = await newContext('member A', { cookie: cookieA }); |
| + const ctxB = await newContext('member B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + |
| + const pair = await assertPair(pageA, pageB, 'member A', 'member B'); |
| + if (!pair) { |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + return; |
| + } |
| + |
| + // room.js reveals the button only where there is an enumeration |
| + // behind it, so its being visible is itself the feature detection |
| + // working. |
| + const shown = await until(async () => await pageA.locator('#devices-toggle').isVisible(), 10000); |
| + check(shown, 'the Devices button is revealed in the room'); |
| + await pageA.click('#devices-toggle'); |
| + check(await pageA.locator('#room-devices').isVisible(), 'the device panel opens'); |
| + check(await pageA.locator('#devices-toggle').getAttribute('aria-expanded') === 'true', |
| + 'the Devices button reports itself expanded'); |
| + |
| + const mics = await until(async () => { |
| + const n = await pageA.locator('#pick-mic option').count(); |
| + return n > 0 ? n : false; |
| + }, 10000); |
| + check(mics, 'the room lists at least one microphone (got ' + mics + ')'); |
| + const cameras = await pageA.locator('#pick-camera option').count(); |
| + check(cameras > 0, 'the room lists at least one camera (got ' + cameras + ')'); |
| + |
| + const sides = [ |
| + { page: pageB, label: 'member B', keys: [pair.a.peer] }, |
| + { page: pageA, label: 'member A', keys: [pair.b.peer] }, |
| + ]; |
| + for (const [selector, what] of [['#pick-mic', 'microphone'], ['#pick-camera', 'camera']]) { |
| + const value = await pageA.locator(selector + ' option').first().getAttribute('value'); |
| + await pageA.selectOption(selector, value); |
| + // The swap is asynchronous behind the change event: one |
| + // getUserMedia and a replaceTrack per connection. Nothing in the |
| + // page announces it finishing, so what is waited for is the thing |
| + // that must be true afterwards — a live local track of that kind |
| + // — and then the media is watched over a duration. |
| + const live = await until(async () => await pageA.evaluate((kind) => { |
| + const tracks = (window.meet && window.meet.local && window.meet.local()) || []; |
| + const of = tracks.filter((t) => t.kind === kind); |
| + return of.length === 1 && of[0].state === 'live' && of[0].chosen; |
| + }, what === 'microphone' ? 'audio' : 'video'), 15000); |
| + check(live, 'member A holds exactly one live ' + what + ' track after the switch'); |
| + |
| + check((await pageA.locator('#device-note').textContent() || '').trim() === '', |
| + 'switching the ' + what + ' reported no trouble (' + |
| + (await pageA.locator('#device-note').textContent()) + ')'); |
| + |
| + await watchMedia(sides, 'after the ' + what + ' was switched: '); |
| + } |
| + |
| + // Three changes with nothing awaited between them, which is one |
| + // person clicking through a list rather than choosing from it. Each |
| + // is a getUserMedia and a replaceTrack across the mesh, and run at |
| + // once they would be two loops writing to the same senders — what |
| + // every peer then receives decided by whichever device opened |
| + // fastest. Serialised, the last click wins and the media never |
| + // stops; the failure this catches is the middle run stopping a |
| + // track the senders were still holding, which reads at the far end |
| + // as the call simply ending. |
| + const camera = await pageA.locator('#pick-camera option').first().getAttribute('value'); |
| + await Promise.all([ |
| + pageA.selectOption('#pick-camera', camera), |
| + pageA.selectOption('#pick-camera', camera), |
| + pageA.selectOption('#pick-camera', camera), |
| + ]); |
| + const settled = await until(async () => await pageA.evaluate(() => { |
| + const tracks = (window.meet && window.meet.local && window.meet.local()) || []; |
| + const video = tracks.filter((t) => t.kind === 'video'); |
| + return video.length === 1 && video[0].state === 'live'; |
| + }), 20000); |
| + check(settled, 'member A holds exactly one live camera after three switches at once — ' + |
| + JSON.stringify(await pageA.evaluate(() => window.meet.local()))); |
| + await watchMedia(sides, 'after three switches at once: '); |
| + |
| + 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(); |
| + }, |
| + |
| // §5.4 and §13's slice-4 sentence: "the higher-id context shares its |
| // screen and the lower receives a second video track, then stops, the |
| // received track ends, the share tile is removed and the camera track |
diff --git a/test/browser/devices.test.mjs b/test/browser/devices.test.mjs| new file mode 100644 |
| index 0000000..6b6c4bc |
| --- /dev/null |
| +++ b/test/browser/devices.test.mjs |
| @@ -0,0 +1,116 @@ |
| +// devices.test.mjs — the two device pickers, held to each other. |
| +// |
| +// node --test test/browser/devices.test.mjs |
| +// |
| +// The holding screen (devices.js) and the room (room.js) both enumerate |
| +// devices and fill the same three selects, and they are separate |
| +// routines because they want different things from the answer: the |
| +// holding screen is choosing what to open, the room is showing what is |
| +// already open. What they must not differ on is the rules — which row |
| +// is hidden when, and what an unlabelled device is called — because a |
| +// person who saw "Microphone 2" on one page and something else on the |
| +// next would reasonably think they had changed device when they had |
| +// not. |
| +// |
| +// This is the cheap half of that, checked without a browser. The |
| +// expensive half is the `switching` scenario in call.mjs, which does a |
| +// real swap on a real call. |
| +import { test } from 'node:test'; |
| +import assert from 'node:assert/strict'; |
| +import { readFileSync } from 'node:fs'; |
| +import { fileURLToPath } from 'node:url'; |
| + |
| +const devicesJS = fileURLToPath(new URL('../../internal/meet/static/devices.js', import.meta.url)); |
| +const roomJS = fileURLToPath(new URL('../../internal/meet/static/room.js', import.meta.url)); |
| + |
| +function lift(source, name, where) { |
| + let start = source.indexOf('function ' + name + '('); |
| + assert.notEqual(start, -1, name + ' is not in ' + where + ' any more — this test is stale, not passing'); |
| + if (source.slice(start - 6, start) === 'async ') start -= 6; |
| + 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 ' + where); |
| +} |
| + |
| +const devicesSrc = readFileSync(devicesJS, 'utf8'); |
| +const roomSrc = readFileSync(roomJS, 'utf8'); |
| + |
| +test('both pages name an unlabelled device the same way', () => { |
| + assert.equal(lift(devicesSrc, 'labelFor', 'devices.js'), lift(roomSrc, 'labelFor', 'room.js')); |
| +}); |
| + |
| +// eslint-disable-next-line no-new-func |
| +const labelFor = new Function(lift(roomSrc, 'labelFor', 'room.js') + '; return labelFor;')(); |
| + |
| +test('an unlabelled device is numbered, not blank', () => { |
| + // enumerateDevices returns empty labels until permission has been |
| + // granted, and on some browsers for some devices even after. A select |
| + // full of blank options is a control nobody can use. |
| + assert.equal(labelFor('videoinput', 1), 'Camera 1'); |
| + assert.equal(labelFor('audioinput', 2), 'Microphone 2'); |
| +}); |
| + |
| +test('both pages hide a row with nothing in it', () => { |
| + // A field with no options is clutter, not help — the rule the holding |
| + // screen has always followed, and the room now has to as well. |
| + for (const [src, where] of [[devicesSrc, 'devices.js'], [roomSrc, 'room.js']]) { |
| + for (const row of ['cameraRow', 'micRow']) { |
| + assert.match(src, new RegExp(row + '\\.hidden = '), |
| + where + ' never hides ' + row); |
| + } |
| + } |
| +}); |
| + |
| +test('both pages gate the speaker row on setSinkId', () => { |
| + // A speaker picker in a browser that cannot route output is a control |
| + // that does nothing, which is the one thing worse than no control. |
| + assert.match(devicesSrc, /speakerRow\.hidden = !hasSinkId \|\| speakerCount === 0;/); |
| + assert.match(roomSrc, /speakerRow\.hidden = !HAS_SINK_ID \|\| speakerCount === 0;/); |
| +}); |
| + |
| +test('the room only ever swaps a track, never adds an m-line', () => { |
| + // The whole reason a mid-call switch is safe is that replaceTrack does |
| + // not renegotiate. A regression that reached for addTrack here would |
| + // put an offer in flight on every device change, straight into the |
| + // glare §5.1 spends 300 ms avoiding — and it would pass every test |
| + // that only looked at the local stream. |
| + const sendTrack = lift(roomSrc, 'sendTrack', 'room.js'); |
| + assert.match(sendTrack, /replaceTrack\(track\)/); |
| + assert.equal(/addTrack|addTransceiver/.test(sendTrack), false, |
| + 'sendTrack must not add tracks or transceivers — replaceTrack is what makes this free'); |
| +}); |
| + |
| +test('switches are serialised, so two clicks cannot interleave', () => { |
| + // Two switches running at once are two replaceTrack loops over the |
| + // same senders, and what every peer ends up receiving is then decided |
| + // by whichever getUserMedia happened to settle last. The queue is what |
| + // makes the last click win instead of the fastest device. |
| + const src = lift(roomSrc, 'switchDevice', 'room.js'); |
| + assert.match(src, /switchQueue\s*=/, 'switchDevice must chain onto the queue, not run immediately'); |
| + assert.match(src, /switchQueue\.then\(/, 'switchDevice must wait for the switch before it'); |
| + // A rejected switch must not take every later switch with it. |
| + assert.match(src, /\.catch\(/); |
| +}); |
| + |
| +test('a switch superseded before it opens anything puts nothing back', () => { |
| + // The three generation checks, and the one place there deliberately |
| + // is not one. Before getUserMedia there is nothing to tidy; between |
| + // getUserMedia and the sender loop the track is this run's alone and |
| + // is stopped; *after* the sender loop it belongs to every sender in |
| + // the mesh, and stopping it there would stop the call's own media |
| + // until a newer switch arrived — which it might never do. |
| + const src = lift(roomSrc, 'applySwitch', 'room.js'); |
| + const guards = src.match(/myGen !== switching/g) || []; |
| + assert.equal(guards.length, 3, 'applySwitch checks its generation ' + guards.length + ' times, expected exactly 3'); |
| + assert.match(src, /for \(const t of fresh\.getTracks\(\)\) t\.stop\(\);/, |
| + 'the superseded path must stop the tracks getUserMedia handed it'); |
| + const afterLoop = src.slice(src.indexOf('await sendTrack(')); |
| + assert.equal(/myGen !== switching/.test(afterLoop), false, |
| + 'there must be no generation check after the sender loop — see the comment there'); |
| +}); |
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs| index dc234e2..68dd7fe 100644 |
| --- a/test/browser/room-lane.test.mjs |
| +++ b/test/browser/room-lane.test.mjs |
| @@ -1228,12 +1228,19 @@ test('a muted or ended track is nobody here to repair', () => { |
| const CAMERA_STALL_POLLS = pin('CAMERA_STALL_POLLS'); |
| const CAMERA_REPAIRS = pin('CAMERA_REPAIRS'); |
| +// `switching` is the room's per-kind device-switch counter, injected |
| +// rather than stubbed inside the box: repairCamera reads it to find out |
| +// whether a device switch took ownership of the sender while it was |
| +// awaiting, and a test that could not move it could not exercise the |
| +// one case where the repair must stand down. |
| +const switching = { audio: 0, video: 0 }; |
| + |
| const cameraBox = new Function( |
| - 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', |
| + 'CAMERA_STALL_POLLS', 'CAMERA_REPAIRS', 'switching', |
| [lift(src, 'judgeCamera'), lift(src, 'cameraReport'), lift(src, 'repairCamera')].join('\n') + |
| '\nreturn { judgeCamera, cameraReport, repairCamera };', |
| ); |
| -const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS); |
| +const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS, switching); |
| // A camera transceiver as the watch sees one, and the record of what |
| // was done to its sender. |
| @@ -1379,6 +1386,37 @@ test('the repair takes the track off its sender and puts it back', async () => { |
| 'the encoder is rebuilt by removing the track and restoring it, in that order'); |
| }); |
| +test('a repair overtaken by a device switch leaves the new track alone', async () => { |
| + // The gap between the two replaceTrack calls is the only place a |
| + // device switch can do damage here: the track captured on the way in |
| + // is the one that switch is retiring, and putting it back would undo |
| + // the switch and leave every peer watching a track that has ended. |
| + // The stall that prompted this repair is still being watched, so |
| + // standing down costs a poll and not a camera. |
| + const c = cameraTx(); |
| + const replace = c.tx.sender.replaceTrack; |
| + c.tx.sender.replaceTrack = async (t) => { |
| + const out = await replace(t); |
| + if (t === null) switching.video += 1; |
| + return out; |
| + }; |
| + await cam.repairCamera(c.tx); |
| + assert.deepEqual(c.calls, ['null'], 'the repair stops once a switch owns the sender'); |
| + switching.video = 0; |
| +}); |
| + |
| +test('a repair does not restore a track that has ended under it', async () => { |
| + const c = cameraTx(); |
| + const replace = c.tx.sender.replaceTrack; |
| + c.tx.sender.replaceTrack = async (t) => { |
| + const out = await replace(t); |
| + if (t === null) c.track.readyState = 'ended'; |
| + return out; |
| + }; |
| + await cam.repairCamera(c.tx); |
| + assert.deepEqual(c.calls, ['null'], 'an ended track is not worth putting back'); |
| +}); |
| + |
| test('a sender that cannot replace its track is left alone', async () => { |
| const c = cameraTx({ noReplace: true }); |
| await assert.doesNotReject(() => cam.repairCamera(c.tx)); |