Calls: no mic warning alone; a bar that always fits; Safari's camera and tab
1. "Your microphone isn't picking anything up" said to Paul alone in a call (Mac 0.4.6). Alone nothing is sent and WebRTC never opens the microphone. MicWatch now counts only once the capture hook has had buffers while a connection was up, and only while one is (MicSample.sending, from the peer sampling; Mac and iPhone share it). The web reads its own level only off a connected connection (selfMicWatched). Muted still never warns. 2. The Mac bar off the left of the window. Not reproduced: a real titled window at Paul's 859 pt, live, animated and quick resizes, 760 x 540, chat and Devices open, all drew the old bar whole and centred. The bar is now sized from the window (CallBarFit): roomy, snug, tight spacing, then the least used controls into a ⋯ popover, never mute, camera, chat or Leave. CallsInterop --bar-layout (TestNativeMacBarLayout, in native-browser) checks layout and pixels at every size. 3. An iPhone in Safari "kept cutting in and out" with the Mac app. The web camera watch reset the whole connection, sound included, for a camera iOS had muted; a muted or disabled track is no longer a stall. Back in front, the room reopens a camera or microphone the system ended and replays tiles it paused (comeBack). iPhone and iPad show "Keep this tab open and the screen on during the call" once a session. make native-webkit: the app with Playwright's WebKit (not iOS Safari) - H.264 both ways, Opus useinbandfec=1, a 300 kbit/s cap, and the tab's media coming back. iPhone app 0.4.8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
23 files changed,
+979
−53
Makefile+12 −1docs/superpowers/specs/2026-09-07-meet-design.md+4 −1internal/calls/static/call-window.js+31 −0internal/calls/static/room.js+59 −4internal/calls/templates/room.html+5 −0internal/callstest/native_browser_test.go+59 −0native/README.md+4 −1native/apple/Sources/CallsInterop/BarLayoutHarness.swift+170 −0native/apple/Sources/CallsInterop/Interop.swift+3 −0native/apple/Sources/CallsKit/AudioLevelRules.swift+24 −2native/apple/Sources/CallsKit/Call.swift+8 −1native/apple/Sources/CallsKit/CallBarFit.swift+73 −0native/apple/Sources/CallsKit/CallFixtures.swift+35 −0native/apple/Sources/CallsKit/MacCallView.swift+107 −27native/apple/Sources/CallsKit/MacFullWindowCall.swift+20 −9native/apple/Tests/CallsKitTests/AudioLevelTests.swift+47 −4native/apple/Tests/CallsKitTests/CallBarFitTests.swift+54 −0native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift+1 −1native/ios/project.yml+1 −1test/browser/native-webkit.mjs+185 −0test/browser/room-hearing.test.mjs+26 −0test/browser/room-lane.test.mjs+35 −1test/browser/room-window.test.mjs+16 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ok amadan.net/oficina/calls/callscore 7.281s ? amadan.net/oficina/calls/cmd/calls [no test files] ok amadan.net/oficina/calls/internal/calls 1.622s ok amadan.net/oficina/calls/internal/calls/apns 0.008s ok amadan.net/oficina/calls/internal/calls/hub 0.299s ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay 0.003s ok amadan.net/oficina/calls/internal/calls/seal 0.002s ok amadan.net/oficina/calls/internal/callstest 8.658s ? amadan.net/oficina/calls/protocol [no test files] ? amadan.net/oficina/calls/scripts/writeestilo [no test files] === migration-check === go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree === race === CGO_ENABLED=1 go test -race ./internal/... ok amadan.net/oficina/calls/internal/calls 11.263s ok amadan.net/oficina/calls/internal/calls/apns 1.020s ok amadan.net/oficina/calls/internal/calls/hub 2.657s ? amadan.net/oficina/calls/internal/calls/icons [no test files] ok amadan.net/oficina/calls/internal/calls/relay 1.015s ok amadan.net/oficina/calls/internal/calls/seal 1.011s ok amadan.net/oficina/calls/internal/callstest 27.219s
diff --git a/Makefile b/Makefile| index d44b73b..0276097 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -178,7 +178,18 @@ native-browser: native-mac |
| # The gate runs CallsInterop, which native-mac does not build; without |
| # this it runs whatever binary a previous build left behind. |
| swift build --package-path native/apple --product CallsInterop |
| - CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 25m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|MacScreenRecovery|BrowserAuthentication|BrowserAppLink|MacAudioSelfTest|MacAudioStartStall)' -v ./internal/callstest/ |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 25m -run 'TestNative(MacBarLayout|MacCallsBrowser|MacChat|MacScreenMotion|MacScreenRecovery|BrowserAuthentication|BrowserAppLink|MacAudioSelfTest|MacAudioStartStall)' -v ./internal/callstest/ |
| + |
| +# The Mac app in a call with Playwright's WebKit (not iOS Safari; see |
| +# test/browser/native-webkit.mjs): codecs, Opus FEC, a thin path, and the |
| +# tab getting its camera and microphone back. Apart from native-browser |
| +# because it needs WebKit installed. |
| +.PHONY: native-webkit |
| +native-webkit: native-mac |
| + swift build --package-path native/apple --product CallsInterop |
| + npm install --no-save playwright@1.62.1 |
| + npx playwright install webkit |
| + CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 10m -run 'TestNativeMacWebKit' -v ./internal/callstest/ |
| # Two apps in one call, sharing screens to each other. Separate from |
| # native-browser because two unsigned apps on one Mac do not reliably |
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md| index fd2dc66..35c6f75 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -2346,7 +2346,10 @@ person can see it rather than infer it: |
| - A microphone meter on one's own tile, measured on what is sent, and |
| "Your microphone isn't picking anything up" when it has been flat for |
| - about five seconds while unmuted. |
| + about five seconds while unmuted — and only once capture has really |
| + started and a connection is up and sending it to someone. Alone in a |
| + call nothing is sent (the apps' WebRTC does not even open the |
| + microphone), and the line never shows (2026-10-03). |
| - A level meter on every other person's tile, measured on what arrives, |
| with "No sound from <name>" when their audio has been flat for about |
| five seconds while they report themselves unmuted, and the Muted badge |
diff --git a/internal/calls/static/call-window.js b/internal/calls/static/call-window.js| index 1592198..52c1635 100644 |
| --- a/internal/calls/static/call-window.js |
| +++ b/internal/calls/static/call-window.js |
| @@ -33,6 +33,19 @@ |
| const CONTROLS_TOUCH_DELAY = 3; |
| const CONTROLS_EXIT_DELAY = 1.5; |
| + // appleTouch: an iPhone or iPad, whatever browser — every one there is |
| + // Safari's engine, with Safari's rule that a tab in the background or |
| + // a locked screen loses its camera, microphone and sound. An iPad asks |
| + // for desktop sites as a Mac, so a "Mac" with a touch screen is one. |
| + // Pure, and lifted by name in test/browser/room-window.test.mjs. |
| + function appleTouch(nav) { |
| + if (!nav) return false; |
| + const platform = String(nav.platform || ''); |
| + const agent = String(nav.userAgent || ''); |
| + if (/^(iPhone|iPad|iPod)/.test(platform) || /\b(iPhone|iPad|iPod)\b/.test(agent)) return true; |
| + return platform === 'MacIntel' && (nav.maxTouchPoints || 0) > 1; |
| + } |
| + |
| function controlsDelay(input) { |
| return input === 'touch' ? CONTROLS_TOUCH_DELAY : CONTROLS_POINTER_DELAY; |
| } |
| @@ -370,6 +383,24 @@ |
| $('floor-dismiss')?.addEventListener('click', () => { floor.hidden = true; }); |
| } |
| + // iPhone and iPad: Safari (and every browser there, which is Safari |
| + // underneath) stops the camera and microphone and lets the call's sound |
| + // lapse once the tab is in the background or the screen locks — a call |
| + // that "keeps cutting in and out" (Till, 2026-10-03). Said once a |
| + // session, put away like the relay notice. An iPad asks for the desktop |
| + // site and calls itself a Mac, so a Mac with a touch screen is one. |
| + const keepOpen = $('keep-open-notice'); |
| + if (keepOpen && appleTouch(navigator)) { |
| + const key = 'calls.keepOpenSeen'; |
| + let seen = false; |
| + try { seen = sessionStorage.getItem(key) === '1'; } catch { /* private mode: show it */ } |
| + if (!seen) { |
| + keepOpen.hidden = false; |
| + try { sessionStorage.setItem(key, '1'); } catch { /* this page only */ } |
| + } |
| + $('keep-open-dismiss')?.addEventListener('click', () => { keepOpen.hidden = true; }); |
| + } |
| + |
| // The notices sit under the title strip — none for a title Calls wrote, |
| // which is not shown — and it keeps its place while faded, so they do |
| // not jump as the controls come and go. |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index 75abb55..1506083 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -5289,6 +5289,17 @@ |
| return rec.silent; |
| } |
| + // selfMicWatched is whether "Your microphone isn't picking anything |
| + // up" may count at all: our level is measurable, we are not muted (the |
| + // Muted state says it already), and a connection is up and sending the |
| + // microphone to someone. Alone in a call nothing is sent, and silence |
| + // there is nobody's problem (the Mac app said it to Paul alone, |
| + // 2026-10-03; the rule is the same on every client). Pure, and lifted |
| + // by name in the node test. |
| + function selfMicWatched(measurable, muted, sending) { |
| + return !!(measurable && !muted && sending); |
| + } |
| + |
| // judgeSpeaking is the tile's highlight: on above SPEAK_ON, off after |
| // SPEAK_HOLD polls under SPEAK_OFF, and held in between. Pure over the |
| // record, and lifted by name in the node test. |
| @@ -5674,7 +5685,10 @@ |
| !refusedTiles.has(id) && entry.pc.connectionState === 'connected'); |
| judgeSilence(rec, now, eligible, now - rec.packetAt < ARRIVING_WINDOW, s.heard); |
| } |
| - if (!selfReport) { |
| + // Our own microphone is read off a connection that is up: one |
| + // that is sending it to someone. Alone, or with nobody connected |
| + // yet, there is nothing to judge (selfMicWatched). |
| + if (!selfReport && entry.pc.connectionState === 'connected') { |
| const sender = audioPart(entry.pc, 'getSenders'); |
| try { |
| selfReport = sender ? await audioReport(sender, 'media-source') : null; |
| @@ -5686,7 +5700,7 @@ |
| const track = local && local.getAudioTracks()[0]; |
| if (selfReport && track && track.readyState === 'live') { |
| const s = sampleInto(selfHearing, selfReport, now); |
| - judgeSilence(selfHearing, now, s.measurable && !localMedia().muted, true, s.heard); |
| + judgeSilence(selfHearing, now, selfMicWatched(s.measurable, localMedia().muted, true), true, s.heard); |
| } else { |
| selfHearing.level = 0; |
| selfHearing.speaking = false; |
| @@ -6543,9 +6557,42 @@ |
| // one. The listener is not async for the same reason the pageshow one |
| // is not. |
| document.addEventListener('visibilitychange', () => { |
| - if (document.visibilityState === 'visible') catchUpRenewal(); |
| + if (document.visibilityState !== 'visible') return; |
| + catchUpRenewal(); |
| + comeBack(); |
| }); |
| + // lostKinds names the kinds of our own media that are gone for good: |
| + // a kind we had, whose every track has ended. Pure, and lifted by name |
| + // in the node test. |
| + function lostKinds(audio, video) { |
| + const out = []; |
| + const gone = (tracks) => tracks.length > 0 && tracks.every((t) => t.readyState === 'ended'); |
| + if (gone(audio || [])) out.push('audio'); |
| + if (gone(video || [])) out.push('video'); |
| + return out; |
| + } |
| + |
| + // comeBack is the tab in front again (2026-10-03, an iPhone that "kept |
| + // cutting in and out"). Safari on iOS pauses a page's media in the |
| + // background and can end its camera and microphone outright; nothing |
| + // brought them back. A device that ended is opened again through the |
| + // switch every device change takes, onto every connection, and a tile |
| + // the system paused is asked to play once more — the same ask a render |
| + // makes, so a Play control showing still waits for the person. |
| + function comeBack() { |
| + if (state.stopped || state.leaveSent || !local) return; |
| + for (const kind of lostKinds(local.getAudioTracks(), local.getVideoTracks())) { |
| + const id = prefs[kind === 'audio' ? 'mic' : 'camera'] || ''; |
| + switchDevice(kind, id).catch((err) => { |
| + console.error('meet: reopen ' + kind, err); |
| + }); |
| + } |
| + for (const [key, t] of tiles) { |
| + if (key !== 'self' && t.video && t.video.paused && shouldPlay(t, false)) startPlaying(key, t); |
| + } |
| + } |
| + |
| async function restoreFromCache(e) { |
| if (!e.persisted || state.stopped || state.leaveSent) return; |
| if (!local) await startLocal(); |
| @@ -6743,7 +6790,15 @@ |
| const track = tx && tx.sender && tx.sender.track; |
| const direction = (tx && (tx.currentDirection || tx.direction)) || ''; |
| const sends = direction === 'sendrecv' || direction === 'sendonly'; |
| - if (!tx || !sends || !track || track.readyState === 'ended' || |
| + // A muted track is the system holding the camera (2026-10-03): iOS |
| + // mutes it, live, while Safari is in the background, the screen is |
| + // locked, or a call or Siri takes the camera, and it encodes nothing |
| + // until it is given back. That is not a stalled encoder, and a reset |
| + // for it rebuilt the whole connection — sound included — every time |
| + // an iPhone's camera paused. Disabled (camera off) is the person's |
| + // own doing, and no more a stall. |
| + if (!tx || !sends || !track || track.readyState === 'ended' || track.muted || |
| + track.enabled === false || |
| !report || typeof report.framesEncoded !== 'number') { |
| entry.cameraFrames = -1; |
| entry.cameraStalls = 0; |
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html| index 45cfda7..3ea7f90 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -74,6 +74,11 @@ |
| the first visit of a tab's session and it stays until it is put |
| away. */}} |
| {{if .Floor}}<div id="floor-notice" class="calls-floor-notice" hidden><p>Calls outside your network may not connect yet.</p><button type="button" id="floor-dismiss" aria-label="Dismiss the relay notice" title="Dismiss">{{icon "x"}}</button></div>{{end}} |
| + {{/* iPhone and iPad (2026-10-03): Safari stops the camera and the |
| + microphone, and lets the call's sound lapse, when the tab goes |
| + to the background or the screen locks. call-window.js shows this |
| + once a session on those devices, with its own Dismiss. */}} |
| + <div id="keep-open-notice" class="calls-floor-notice" hidden><p>Keep this tab open and the screen on during the call.</p><button type="button" id="keep-open-dismiss" aria-label="Dismiss the keep-open notice" title="Dismiss">{{icon "x"}}</button></div> |
| <div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div> |
| </div> |
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go| index efb643f..cb31284 100644 |
| --- a/internal/callstest/native_browser_test.go |
| +++ b/internal/callstest/native_browser_test.go |
| @@ -861,3 +861,62 @@ func envOr(key, fallback string) string { |
| } |
| return fallback |
| } |
| + |
| +// TestNativeMacBarLayout holds the full-window call's control bar whole |
| +// and centred at every window size (2026-10-03: Paul's 0.4.6 window |
| +// showed it off the left edge with mute and camera cut off). It needs no |
| +// server: CallsInterop draws the call from fixtures in a real titled |
| +// window, resizes it live, animated and below the app's minimum, and |
| +// checks the layout and what was drawn (BarLayoutHarness.swift). |
| +// CALLS_BAR_SNAPSHOTS keeps the pictures. |
| +func TestNativeMacBarLayout(t *testing.T) { |
| + root, err := filepath.Abs("../..") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| + if _, err := os.Stat(binary); err != nil { |
| + t.Fatal("build native interop executable with make native-mac first") |
| + } |
| + folder := os.Getenv("CALLS_BAR_SNAPSHOTS") |
| + if folder == "" { |
| + folder = t.TempDir() |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) |
| + defer cancel() |
| + out, err := exec.CommandContext(ctx, binary, "--bar-layout", folder).CombinedOutput() |
| + t.Log(string(out)) |
| + if err != nil { |
| + t.Fatalf("bar layout: %v", err) |
| + } |
| +} |
| + |
| +// TestNativeMacWebKit is the Mac app in a call with Safari's engine |
| +// (Playwright's WebKit, which is not iOS Safari: see native-webkit.mjs |
| +// for what it can and cannot stand in for). 2026-10-03: a call between |
| +// the Mac app and an iPhone in Safari "kept cutting in and out". It is |
| +// `make native-webkit`, apart from native-browser, because it needs |
| +// Playwright's WebKit installed. |
| +func TestNativeMacWebKit(t *testing.T) { |
| + rig := newBrowserRig(t) |
| + room, token := rig.createMeeting(t) |
| + root, err := filepath.Abs("../..") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| + if _, err := os.Stat(binary); err != nil { |
| + t.Fatal("build native interop executable with make native-mac first") |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 180*time.Second) |
| + defer cancel() |
| + cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native-webkit.mjs"), rig.origin, room, binary) |
| + cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| + input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies), "invitation": rig.origin + "/j/" + token}) |
| + cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input)) |
| + out, err := cmd.CombinedOutput() |
| + t.Log(string(out)) |
| + if err != nil { |
| + t.Fatalf("native-webkit: %v", err) |
| + } |
| +} |
diff --git a/native/README.md b/native/README.md| index 0405e19..b868347 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -126,7 +126,10 @@ because nothing on it measured sound. Now: |
| Unmuted and capturing, with nothing reaching the hook above -90 dBFS |
| *before* the suppressor for five seconds, the tile says "Your microphone |
| isn't picking anything up", and its ⓘ names the device and input being sent |
| - ("Scarlett 18i8 USB, input 1"). |
| + ("Scarlett 18i8 USB, input 1"). Only once the hook has had buffers while a |
| + connection was up, and only while one still is (`MicSample.sending`): |
| + alone in a call WebRTC never opens the microphone, and 0.4.6 told Paul, |
| + alone, that his was dead (2026-10-03). |
| - **Everyone else's tile** meters what arrives from them: a renderer on their |
| remote audio track (`RemoteAudioTap`), which sees each decoded frame before |
| the output volume, read at 5 Hz. Muted, the tile shows the Muted badge and no |
diff --git a/native/apple/Sources/CallsInterop/BarLayoutHarness.swift b/native/apple/Sources/CallsInterop/BarLayoutHarness.swift| new file mode 100644 |
| index 0000000..45db033 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/BarLayoutHarness.swift |
| @@ -0,0 +1,170 @@ |
| +import AppKit |
| +@_spi(Fixtures) import CallsKit |
| +import SwiftUI |
| + |
| +/// The full-window call's control bar at every window size (2026-10-03: |
| +/// Paul's 0.4.6 window showed it off the left edge, mute and camera cut |
| +/// off). A real, titled, resizable window as the app's — content to the top, |
| +/// the app's 760 × 540 minimum on its content — taken through a run of |
| +/// sizes by resizing the one window live, as a person dragging its edge |
| +/// does, and set below the minimum the way only the system can (tiling, |
| +/// a display change). At each size the bar must be whole inside the |
| +/// window, centred on it, and its buttons inside the bar. A snapshot of |
| +/// each goes to the folder. |
| +@MainActor enum BarLayoutHarness { |
| + struct Step { |
| + var name: String |
| + var size: CGSize |
| + /// Captured as soon as the resize has laid out, not after it settles. |
| + var quick = false |
| + /// Resized as the system's own animation does (zoom, tiling). |
| + var animate = false |
| + } |
| + |
| + static let steps: [Step] = [ |
| + Step(name: "1000x760", size: CGSize(width: 1000, height: 760)), |
| + // Paul's screenshot was 1718 px wide: 859 pt on a Retina display. |
| + Step(name: "859x700", size: CGSize(width: 859, height: 700)), |
| + Step(name: "760x540", size: CGSize(width: 760, height: 540)), |
| + Step(name: "1718x1000", size: CGSize(width: 1718, height: 1000)), |
| + Step(name: "1718x1000-to-800-quick", size: CGSize(width: 800, height: 600), quick: true), |
| + Step(name: "800x600", size: CGSize(width: 800, height: 600)), |
| + // Below the minimum: a window the system made narrower than the app |
| + // asked for. |
| + Step(name: "640x480-forced", size: CGSize(width: 640, height: 480)), |
| + Step(name: "480x400-forced", size: CGSize(width: 480, height: 400)), |
| + Step(name: "360x400-forced", size: CGSize(width: 360, height: 400)), |
| + Step(name: "1200x800-back", size: CGSize(width: 1200, height: 800)), |
| + // Half of a 1728-pt display, as window tiling makes it, animated. |
| + Step(name: "859x1080-tiled", size: CGSize(width: 859, height: 1080), animate: true), |
| + Step(name: "1718x1080-zoomed", size: CGSize(width: 1718, height: 1080), animate: true), |
| + Step(name: "859x1080-tiled-quick", size: CGSize(width: 859, height: 1080), quick: true, animate: true), |
| + ] |
| + |
| + static func run(to folder: URL) async -> Bool { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| + var failures: [String] = [] |
| + // The last two: no 760 × 540 minimum, as a window the system has made |
| + // narrower than the app asked for — the widths only ⋯ can serve. |
| + let variants: [(CallFixture, Bool, Bool, Bool)] = [ |
| + (.alone, false, false, true), (.alone, true, false, true), (.alone, false, true, true), |
| + (.grid, false, false, true), (.grid, true, false, true), (.grid, false, true, true), |
| + (.alone, false, false, false), (.grid, false, true, false), |
| + ] |
| + for (fixture, chat, devices, minimum) in variants { |
| + do { |
| + let call = Call(playAudio: false) |
| + fixture.apply(to: call) |
| + let content = MacCallView( |
| + call: call, grid: false, showingChat: chat, showingDevices: devices, style: .fullWindow, |
| + controlsShown: true, leave: {}, rejoin: {} |
| + ) |
| + .tint(CallsAppearance.accent) |
| + // As the app's window has it (CallsMac.swift). |
| + .frame(minWidth: minimum ? 760 : 0, minHeight: minimum ? 540 : 0) |
| + let view = NSHostingView(rootView: content.environment(\.colorScheme, .dark)) |
| + let window = NSWindow( |
| + contentRect: NSRect(origin: CGPoint(x: 40, y: 40), size: steps[0].size), |
| + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], |
| + backing: .buffered, defer: false) |
| + window.isReleasedWhenClosed = false |
| + window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) |
| + window.appearance = NSAppearance(named: .darkAqua) |
| + window.contentView = view |
| + if !minimum { window.contentMinSize = CGSize(width: 200, height: 200) } |
| + window.orderBack(nil) |
| + try? await Task.sleep(for: .milliseconds(700)) |
| + let variant = |
| + "\(fixture.rawValue)\(chat ? "-chat" : "")\(devices ? "-devices" : "")\(minimum ? "" : "-nominimum")" |
| + for step in steps { |
| + window.setFrame( |
| + NSRect(origin: window.frame.origin, size: step.size), display: true, animate: step.animate) |
| + view.layoutSubtreeIfNeeded() |
| + try? await Task.sleep(for: .milliseconds(step.quick ? 30 : 500)) |
| + let width = view.bounds.width |
| + let pane = CallBarProbe.pane |
| + let row = CallBarProbe.row |
| + let name = "\(variant)-\(step.name)" |
| + var problems: [String] = [] |
| + if pane.minX < -0.5 || pane.maxX > width + 0.5 { |
| + problems.append("bar \(fmt(pane)) leaves the window (width \(fmt(width)))") |
| + } |
| + if row.minX < pane.minX - 0.5 || row.maxX > pane.maxX + 0.5 { |
| + problems.append("buttons \(fmt(row)) spill out of the bar \(fmt(pane))") |
| + } |
| + let off = abs(pane.midX - width / 2) |
| + if off > 1 { problems.append("bar off centre by \(fmt(off)) (bar \(fmt(pane)), window \(fmt(width)))") } |
| + if pane.width < 1 { problems.append("bar not laid out") } |
| + let line = "\(name): window \(fmt(width)) bar \(fmt(pane)) buttons \(fmt(row))" |
| + print(problems.isEmpty ? "ok \(line)" : "FAIL \(line): \(problems.joined(separator: "; "))") |
| + failures += problems.map { "\(name): \($0)" } |
| + let file = folder.appendingPathComponent("bar-\(name).png") |
| + capture(window, to: file) |
| + // What was drawn, not only what was laid out: the red of Leave |
| + // in the picture must be where the layout put Leave, and whole. |
| + if let red = leaveRed(in: file, windowWidth: window.frame.width) { |
| + let expected = CallBarProbe.last |
| + if abs(red.midX - expected.midX) > 3 || red.width < expected.width - 4 { |
| + let problem = |
| + "Leave drawn at \(fmt(red)), laid out at \(fmt(expected))" |
| + print("FAIL \(name): \(problem)") |
| + failures.append("\(name): \(problem)") |
| + } |
| + } else { |
| + print("FAIL \(name): no Leave in the picture") |
| + failures.append("\(name): no Leave in the picture") |
| + } |
| + } |
| + window.close() |
| + } |
| + } |
| + print(failures.isEmpty ? "bar layout: all sizes ok" : "bar layout: \(failures.count) failures") |
| + return failures.isEmpty |
| + } |
| + |
| + private static func fmt(_ value: CGFloat) -> String { String(format: "%.1f", value) } |
| + private static func fmt(_ rect: CGRect) -> String { |
| + "\(fmt(rect.minX))…\(fmt(rect.maxX)) (\(fmt(rect.width)))" |
| + } |
| + |
| + /// The horizontal extent, in points, of Leave's red in a capture of a |
| + /// window `windowWidth` points wide. |
| + private static func leaveRed(in file: URL, windowWidth: CGFloat) -> CGRect? { |
| + guard let image = NSImage(contentsOf: file), |
| + let cg = image.cgImage(forProposedRect: nil, context: nil, hints: nil), |
| + let data = cg.dataProvider?.data, let bytes = CFDataGetBytePtr(data) |
| + else { return nil } |
| + let width = cg.width |
| + let height = cg.height |
| + let row = cg.bytesPerRow |
| + let step = cg.bitsPerPixel / 8 |
| + let scale = CGFloat(width) / windowWidth |
| + let bgr = cg.bitmapInfo.contains(.byteOrder32Little) |
| + var minX = Int.max |
| + var maxX = Int.min |
| + // The bottom 140 points: the bar and its margin. |
| + let top = max(0, height - Int(140 * scale)) |
| + for y in top..<height { |
| + for x in 0..<width { |
| + let p = bytes + y * row + x * step |
| + let (r, g, b) = bgr ? (p[2], p[1], p[0]) : (p[0], p[1], p[2]) |
| + // leaveFill is (0.84, 0.16, 0.14): about (214, 41, 36). |
| + if r > 170, g < 90, b < 90 { |
| + minX = min(minX, x) |
| + maxX = max(maxX, x) |
| + } |
| + } |
| + } |
| + guard minX <= maxX else { return nil } |
| + return CGRect(x: CGFloat(minX) / scale, y: 0, width: CGFloat(maxX - minX + 1) / scale, height: 1) |
| + } |
| + |
| + private static func capture(_ window: NSWindow, to file: URL) { |
| + let capture = Process() |
| + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| + capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), file.path] |
| + try? capture.run() |
| + capture.waitUntilExit() |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index af6e902..603c858 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -52,6 +52,9 @@ struct Input: Decodable { |
| } catch { exit(1) } |
| return |
| } |
| + if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--bar-layout" { |
| + exit(await BarLayoutHarness.run(to: URL(fileURLWithPath: CommandLine.arguments[2])) ? 0 : 1) |
| + } |
| if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-start-stall" { |
| exit(await AudioStartStallHarness.run() ? 0 : 1) |
| } |
diff --git a/native/apple/Sources/CallsKit/AudioLevelRules.swift b/native/apple/Sources/CallsKit/AudioLevelRules.swift| index 077d849..8eda4d6 100644 |
| --- a/native/apple/Sources/CallsKit/AudioLevelRules.swift |
| +++ b/native/apple/Sources/CallsKit/AudioLevelRules.swift |
| @@ -158,13 +158,21 @@ public struct MicSample: Equatable, Sendable { |
| public var inputBuffers: Int |
| /// The loudest sample leaving the hook — what is sent — for the meter. |
| public var sentPeak: Float |
| + /// At least one connection is up and carrying this microphone to |
| + /// someone. Alone in a call there is none, WebRTC never opens the |
| + /// microphone, and silence says nothing about it (Mac 0.4.6 said "Your |
| + /// microphone isn't picking anything up" to Paul alone in a call). |
| + public var sending: Bool |
| - public init(muted: Bool, capturing: Bool, inputPeak: Float, inputBuffers: Int, sentPeak: Float) { |
| + public init( |
| + muted: Bool, capturing: Bool, inputPeak: Float, inputBuffers: Int, sentPeak: Float, sending: Bool |
| + ) { |
| self.muted = muted |
| self.capturing = capturing |
| self.inputPeak = inputPeak |
| self.inputBuffers = inputBuffers |
| self.sentPeak = sentPeak |
| + self.sending = sending |
| } |
| } |
| @@ -180,6 +188,10 @@ public struct MicWatch: Equatable, Sendable { |
| private var flat = Held() |
| private var gate = SpeakingGate() |
| + /// The hook has been handed audio while a connection was sending it: |
| + /// the microphone really did start for someone. Until then there is |
| + /// nothing to judge — alone, or before WebRTC has opened it. |
| + private var started = false |
| public private(set) var step = 0 |
| public private(set) var silent = false |
| public private(set) var speaking = false |
| @@ -192,14 +204,24 @@ public struct MicWatch: Equatable, Sendable { |
| guard !sample.muted, sample.capturing else { |
| _ = flat.update(false, now: now) |
| gate.reset() |
| + started = false |
| step = 0 |
| silent = false |
| speaking = false |
| return |
| } |
| + // The warning needs both halves (2026-10-03): capture has started — |
| + // the hook has had buffers while someone was being sent to — and a |
| + // connection is still sending. Alone, nobody is sent to, so nobody |
| + // is told their microphone is dead. |
| + if !sample.sending { |
| + started = false |
| + } else if sample.inputBuffers > 0 { |
| + started = true |
| + } |
| let dead = |
| sample.inputBuffers == 0 || MeterScale.decibels(sample.inputPeak) < Self.silentDb |
| - silent = flat.update(dead, now: now) >= Self.silentAfter |
| + silent = flat.update(started && dead, now: now) >= Self.silentAfter |
| step = MeterScale.step(sample.sentPeak) |
| speaking = gate.update(level: sample.sentPeak, now: now) |
| } |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index fe840b0..06db68d 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -168,6 +168,11 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| /// they had risen: "arriving" for the no-sound rule. |
| private var audioBytes: [String: UInt64] = [:] |
| private var audioArriving: [String: Bool] = [:] |
| + /// Some connection is up, and so carrying the microphone to someone |
| + /// (every connection is given the track when it is made). Read from the |
| + /// peer sampling, five times a second; the microphone warning waits on |
| + /// it (MicSample.sending). |
| + private var sendingAudio = false |
| /// The loudest remote sound since the gate last asked (sampleStats). |
| private var remotePeakSinceSample: Float = 0 |
| private var micPeakSinceSample: Float = 0 |
| @@ -563,6 +568,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| reportedNoSound = [] |
| audioBytes = [:] |
| audioArriving = [:] |
| + sendingAudio = false |
| } |
| private func resetConnecting() { |
| @@ -591,7 +597,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| levels.updateMic( |
| MicSample( |
| muted: muted || media.localAudio == nil, capturing: capturing, |
| - inputPeak: heard.peak, inputBuffers: heard.buffers, sentPeak: sent.peak), |
| + inputPeak: heard.peak, inputBuffers: heard.buffers, sentPeak: sent.peak, sending: sendingAudio), |
| now: now, source: source, channels: channels) |
| AudioDiagnosticsStore.shared.record( |
| .init( |
| @@ -618,6 +624,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| } |
| let readings = await media.peerAudio(stats: stats, detail: detail, acks: acks) |
| guard levelWatch != nil, let state else { return } |
| + sendingAudio = readings.values.contains { $0.connected } |
| let me = state.peer |
| var present = Set<String>() |
| var people: [String: ReconnectWatch.Peer] = [:] |
diff --git a/native/apple/Sources/CallsKit/CallBarFit.swift b/native/apple/Sources/CallsKit/CallBarFit.swift| new file mode 100644 |
| index 0000000..345900b |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CallBarFit.swift |
| @@ -0,0 +1,73 @@ |
| +import CoreGraphics |
| + |
| +/// How the full-window call's control bar fits the width it has (2026-10-03: |
| +/// Paul's 0.4.6 bar ran off the left of the window, mute and camera cut |
| +/// off). The bar is always whole and centred: first its spacing tightens, |
| +/// then the controls that matter least move into a ⋯ menu, one at a time — |
| +/// never mute, camera, chat or Leave. |
| +public struct CallBarFit: Equatable, Sendable { |
| + /// A control the bar can hold. `essential` ones never move into ⋯. |
| + public enum Control: String, CaseIterable, Sendable { |
| + case mute, camera, share, devices, layout, link, chat, leave |
| + |
| + public var essential: Bool { [.mute, .camera, .chat, .leave].contains(self) } |
| + |
| + /// Its width on the bar: every button is 44 pt round; Leave is a |
| + /// capsule 1.4 times as wide (OverlayButtonBody). |
| + var width: CGFloat { self == .leave ? CallBarFit.button * 1.4 : CallBarFit.button } |
| + } |
| + |
| + public static let button: CGFloat = 44 |
| + /// The widest the bar grows, and its margin from the window's edges. |
| + public static let maxWidth: CGFloat = 760 |
| + public static let margin: CGFloat = 16 |
| + /// The order the optional controls move into ⋯: the least used first. |
| + public static let overflowOrder: [Control] = [.link, .layout, .devices, .share] |
| + |
| + /// One step of tightening: the gap between buttons, the least room |
| + /// between the left group and chat, and the pane's padding. |
| + public struct Spacing: Equatable, Sendable { |
| + public var between: CGFloat |
| + public var gap: CGFloat |
| + public var padding: CGFloat |
| + } |
| + |
| + public static let roomy = Spacing(between: 10, gap: 24, padding: 10) |
| + public static let snug = Spacing(between: 6, gap: 12, padding: 8) |
| + public static let tight = Spacing(between: 4, gap: 4, padding: 6) |
| + |
| + public var spacing: Spacing |
| + /// The controls in ⋯, in the bar's own order; empty when everything fits. |
| + public var overflow: [Control] |
| + /// The pane's width: as wide as it may be, never wider than it has. |
| + public var width: CGFloat |
| + |
| + /// The bar for a window `windowWidth` wide showing `controls` (Share |
| + /// meeting link is only there for a member; Video layout only here). |
| + public static func fit(windowWidth: CGFloat, controls: [Control]) -> CallBarFit { |
| + let room = max(0, windowWidth - 2 * margin) |
| + for spacing in [roomy, snug, tight] where needed(controls, spacing, more: false) <= room { |
| + return CallBarFit(spacing: spacing, overflow: [], width: min(maxWidth, room)) |
| + } |
| + var shown = controls |
| + var moved: [Control] = [] |
| + for control in overflowOrder where shown.contains(control) { |
| + shown.removeAll { $0 == control } |
| + moved.append(control) |
| + if needed(shown, tight, more: true) <= room { break } |
| + } |
| + let order = controls.filter { moved.contains($0) } |
| + return CallBarFit(spacing: tight, overflow: order, width: min(maxWidth, room)) |
| + } |
| + |
| + /// The least width `controls` take with `spacing`, with a ⋯ button if |
| + /// `more`: the buttons, a gap between each (the spacer before chat is |
| + /// one more child of the row), the spacer's own least, and the padding. |
| + public static func needed(_ controls: [Control], _ spacing: Spacing, more: Bool) -> CGFloat { |
| + let buttons = controls.reduce(0) { $0 + $1.width } + (more ? button : 0) |
| + let children = controls.count + (more ? 1 : 0) + 1 |
| + return buttons + CGFloat(children - 1) * spacing.between + spacing.gap + 2 * spacing.padding |
| + } |
| + |
| + public func shows(_ control: Control) -> Bool { !overflow.contains(control) } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| index 0f954e7..89f70b0 100644 |
| --- a/native/apple/Sources/CallsKit/CallFixtures.swift |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -22,6 +22,9 @@ import SwiftUI |
| /// The focus call in a meeting someone named: its title is shown, |
| /// centred where a window's title would be (CallTitle). |
| case named |
| + /// Nobody else here yet: "Waiting for others", and you in the corner |
| + /// (Paul's 0.4.6 screenshot, 2026-10-03). |
| + case alone |
| public static let title = "Call, 24 Sep 10:21" |
| public static let namedTitle = "Board meeting" |
| @@ -55,6 +58,8 @@ import SwiftUI |
| call.showFixtureConnecting(["sam", "bea"]) |
| case .named: |
| call.showFixture(title: Self.namedTitle, state: roster("admitted", [me, ana]), named: true) |
| + case .alone: |
| + call.showFixture(title: Self.title, state: roster("admitted", [me])) |
| } |
| } |
| } |
| @@ -218,3 +223,33 @@ extension EnvironmentValues { |
| } |
| } |
| } |
| + |
| +#if os(macOS) |
| + /// Where the full-window call's control bar last laid out, in its |
| + /// window's coordinates (top-left origin), for the bar layout gate |
| + /// (`CallsInterop --bar-layout`): the glass pane, and the row of |
| + /// buttons on it, which overflow the pane if it is too narrow for them. |
| + @_spi(Fixtures) @MainActor public enum CallBarProbe { |
| + public static var pane: CGRect = .zero |
| + /// The first button on the bar and the last (Leave). |
| + public static var first: CGRect = .zero |
| + public static var last: CGRect = .zero |
| + public static var row: CGRect { first.union(last) } |
| + } |
| + |
| + /// Records a bar button's frame for CallBarProbe; nothing else reads it. |
| + struct CallBarEndProbe: ViewModifier { |
| + let last: Bool |
| + func body(content: Content) -> some View { |
| + content.background( |
| + GeometryReader { button in |
| + Color.clear |
| + .onAppear { record(button.frame(in: .global)) } |
| + .onChange(of: button.frame(in: .global)) { _, now in record(now) } |
| + }) |
| + } |
| + @MainActor private func record(_ frame: CGRect) { |
| + if last { CallBarProbe.last = frame } else { CallBarProbe.first = frame } |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index c92b664..f2e1d2e 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -15,6 +15,8 @@ |
| @State var readChatCount = 0 |
| @State var showingDevices: Bool |
| @State var showingLayout = false |
| + @State var showingInvitation = false |
| + @State var showingMore = false |
| @State var devicesHeight: CGFloat = 360 |
| @StateObject var chatWindow = MacChatWindow() |
| @StateObject var chrome: ControlsVisibilityModel |
| @@ -338,11 +340,19 @@ |
| return CGSize(width: min(180, stage.width * 0.3, height * 4 / 3), height: height) |
| } |
| + /// The controls this person's bar holds, in order (CallBarFit). |
| + var barControls: [CallBarFit.Control] { |
| + CallBarFit.Control.allCases.filter { $0 != .link || call.standing == "member" } |
| + } |
| + |
| /// The call's buttons, in the classic row or the full-window bar. The |
| /// bar adds the layout menu (the classic view has its picker above the |
| /// stage) and tells the controls' visibility when one has focus. |
| - func controls(fullWindow: Bool) -> some View { |
| - HStack(spacing: fullWindow ? 10 : 12) { |
| + /// `fit` is the full-window bar's, which may tighten them and move |
| + /// some into ⋯ (CallBarFit); the classic row has none and shows |
| + /// everything. |
| + func controls(fullWindow: Bool, fit: CallBarFit? = nil) -> some View { |
| + HStack(spacing: fit?.spacing.between ?? (fullWindow ? 10 : 12)) { |
| Button( |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| @@ -355,6 +365,7 @@ |
| .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| .accessibilityIdentifier("mute") |
| .focused($focusedControl, equals: .mute) |
| + .modifier(CallBarEndProbe(last: false)) |
| Button( |
| call.cameraOff ? "Turn camera on" : "Turn camera off", |
| glyph: call.cameraOff ? "video.slash.fill" : "video.fill" |
| @@ -367,27 +378,30 @@ |
| .help(call.cameraOff ? "Turn camera on" : "Turn camera off") |
| .accessibilityIdentifier("camera") |
| .focused($focusedControl, equals: .camera) |
| - Button( |
| - call.sharingScreen ? "Stop sharing your screen" : "Share your screen", |
| - glyph: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| - ) { |
| - if call.sharingScreen { |
| - call.stopSharingScreen() |
| - } else { |
| - Task { await call.shareScreen(MacScreenCapture()) } |
| + if fit?.shows(.share) ?? true { |
| + Button( |
| + call.sharingScreen ? "Stop sharing your screen" : "Share your screen", |
| + glyph: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| + ) { |
| + if call.sharingScreen { |
| + call.stopSharingScreen() |
| + } else { |
| + Task { await call.shareScreen(MacScreenCapture()) } |
| + } |
| } |
| + .buttonStyle( |
| + CallActionStyle( |
| + color: .orange, emphasized: call.sharingScreen, |
| + overlay: tone(fullWindow, call.sharingScreen ? .on : .normal))) |
| + .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen") |
| + .disabled(call.state?.phase != "admitted") |
| + .accessibilityIdentifier("shareScreen") |
| + .focused($focusedControl, equals: .share) |
| } |
| - .buttonStyle( |
| - CallActionStyle( |
| - color: .orange, emphasized: call.sharingScreen, |
| - overlay: tone(fullWindow, call.sharingScreen ? .on : .normal))) |
| - .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen") |
| - .disabled(call.state?.phase != "admitted") |
| - .accessibilityIdentifier("shareScreen") |
| - .focused($focusedControl, equals: .share) |
| - Button("Devices", glyph: "slider.horizontal.3") { |
| - withAnimation(panelAnimation) { showingDevices.toggle() } |
| - } |
| + if fit?.shows(.devices) ?? true { |
| + Button("Devices", glyph: "slider.horizontal.3") { |
| + withAnimation(panelAnimation) { showingDevices.toggle() } |
| + } |
| .buttonStyle( |
| CallActionStyle( |
| color: .teal, emphasized: showingDevices, |
| @@ -395,14 +409,20 @@ |
| .help("Choose the camera, microphone and speaker for this call") |
| .accessibilityIdentifier("devices") |
| .focused($focusedControl, equals: .devices) |
| - if fullWindow { |
| + } |
| + if fullWindow, fit?.shows(.layout) ?? true { |
| layoutMenu.focused($focusedControl, equals: .layout) |
| } |
| - MeetingShareButton(call: call) |
| - .buttonStyle(CallActionStyle(color: .indigo, overlay: tone(fullWindow, .normal))) |
| - .help("Share meeting link") |
| - .focused($focusedControl, equals: .link) |
| - Spacer(minLength: fullWindow ? 24 : nil) |
| + if fit?.shows(.link) ?? true { |
| + MeetingShareButton(call: call) |
| + .buttonStyle(CallActionStyle(color: .indigo, overlay: tone(fullWindow, .normal))) |
| + .help("Share meeting link") |
| + .focused($focusedControl, equals: .link) |
| + } |
| + if let fit, !fit.overflow.isEmpty { |
| + moreMenu(fit.overflow) |
| + } |
| + Spacer(minLength: fit?.spacing.gap ?? (fullWindow ? 24 : nil)) |
| Button(chatTitle, glyph: "bubble.left.and.bubble.right.fill") { |
| if chatWindow.isOpen { chatWindow.close() } |
| withAnimation(panelAnimation) { showingChat.toggle() } |
| @@ -432,9 +452,69 @@ |
| .help("Leave meeting") |
| .accessibilityIdentifier("leave") |
| .focused($focusedControl, equals: .leave) |
| + .modifier(CallBarEndProbe(last: true)) |
| }.frame(maxWidth: .infinity) |
| } |
| + /// ⋯ on a bar too narrow for everything (CallBarFit): the controls |
| + /// that did not fit, as menu items, in the bar's order. |
| + private func moreMenu(_ overflow: [CallBarFit.Control]) -> some View { |
| + Button("More controls", glyph: "ellipsis") { showingMore.toggle() } |
| + .buttonStyle(CallActionStyle(color: .gray, emphasized: showingMore, overlay: showingMore ? .on : .normal)) |
| + .help("More controls") |
| + .accessibilityIdentifier("moreControls") |
| + .popover(isPresented: $showingMore, arrowEdge: .top) { |
| + VStack(alignment: .leading, spacing: 10) { |
| + ForEach(overflow, id: \.self) { control in |
| + moreItem(control) |
| + } |
| + } |
| + .buttonStyle(.borderless) |
| + .padding(14) |
| + .frame(minWidth: 220, alignment: .leading) |
| + } |
| + .sheet(isPresented: $showingInvitation) { |
| + MeetingInvitationView(call: call, close: { showingInvitation = false }) |
| + .task { await call.loadInvitation() } |
| + } |
| + } |
| + |
| + @ViewBuilder private func moreItem(_ control: CallBarFit.Control) -> some View { |
| + switch control { |
| + case .share: |
| + Button( |
| + call.sharingScreen ? "Stop sharing your screen" : "Share your screen", |
| + systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| + ) { |
| + showingMore = false |
| + if call.sharingScreen { |
| + call.stopSharingScreen() |
| + } else { |
| + Task { await call.shareScreen(MacScreenCapture()) } |
| + } |
| + } |
| + .disabled(call.state?.phase != "admitted") |
| + case .devices: |
| + Button(showingDevices ? "Hide devices" : "Devices", systemImage: "slider.horizontal.3") { |
| + showingMore = false |
| + withAnimation(panelAnimation) { showingDevices.toggle() } |
| + } |
| + case .layout: |
| + layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize() |
| + if !grid, peers.count > 1 || !call.screens.isEmpty { |
| + Text("On the stage").font(.headline) |
| + VStack(alignment: .leading, spacing: 6) { pinChoices } |
| + } |
| + case .link: |
| + Button("Share meeting link", systemImage: "square.and.arrow.up") { |
| + showingMore = false |
| + showingInvitation = true |
| + } |
| + default: |
| + EmptyView() |
| + } |
| + } |
| + |
| /// Speaker or Grid, and in Speaker, what is pinned to the stage: the |
| /// classic view's picker and pin menu, folded into one bar button and |
| /// the small popover it opens. |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index c0a071d..d859891 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -48,7 +48,7 @@ |
| .frame(maxHeight: .infinity, alignment: .top) |
| topOverlay |
| panels(in: size) |
| - bar |
| + bar(in: size) |
| } |
| .coordinateSpace(name: "call") |
| .frame(width: size.width, height: size.height) |
| @@ -78,6 +78,7 @@ |
| .onChange(of: showingDevices) { updateHolds() } |
| .onChange(of: showingChat) { updateHolds() } |
| .onChange(of: showingLayout) { updateHolds() } |
| + .onChange(of: showingMore) { updateHolds() } |
| .onChange(of: menuTracking) { updateHolds() } |
| .onChange(of: sheetShowing) { updateHolds() } |
| .onChange(of: voiceOver) { updateHolds() } |
| @@ -115,7 +116,8 @@ |
| } |
| private func updateHolds() { |
| - chrome.set(.menuOpen, showingDevices || showingChat || showingLayout || menuTracking) |
| + chrome.set( |
| + .menuOpen, showingDevices || showingChat || showingLayout || showingMore || menuTracking) |
| chrome.set(.alert, sheetShowing) |
| chrome.set(.voiceOver, voiceOver) |
| } |
| @@ -482,22 +484,31 @@ |
| // MARK: Bar |
| - private var bar: some View { |
| - controls(fullWindow: true) |
| - .padding(10) |
| + /// The bar: centred on the window and whole at every width |
| + /// (CallBarFit), its width set from the window's rather than left to |
| + /// whatever its row asks for — tightening, then moving the least used |
| + /// controls into ⋯, before anything could reach past an edge. |
| + private func bar(in size: CGSize) -> some View { |
| + let fit = CallBarFit.fit(windowWidth: size.width, controls: barControls) |
| + return controls(fullWindow: true, fit: fit) |
| + .padding(fit.spacing.padding) |
| + .frame(width: fit.width) |
| // One glass pane, no edge, the buttons sitting on it. |
| .callOverlaySurface(Capsule()) |
| .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| - .frame(maxWidth: 760) |
| .background( |
| GeometryReader { bar in |
| Color.clear |
| - .onAppear { barFrame = bar.frame(in: .named("call")) } |
| + .onAppear { |
| + barFrame = bar.frame(in: .named("call")) |
| + CallBarProbe.pane = bar.frame(in: .global) |
| + } |
| .onChange(of: bar.frame(in: .named("call"))) { _, now in barFrame = now } |
| + .onChange(of: bar.frame(in: .global)) { _, now in CallBarProbe.pane = now } |
| } |
| ) |
| - .padding(.horizontal, 16).padding(.bottom, 16) |
| - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) |
| + .padding(.bottom, CallBarFit.margin) |
| + .frame(width: size.width, height: size.height, alignment: .bottom) |
| .opacity(chrome.visible ? 1 : 0) |
| .allowsHitTesting(chrome.visible) |
| // Hidden from sight but never from VoiceOver, which holds the bar |
diff --git a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift| index ed82243..1fd4cb1 100644 |
| --- a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift |
| @@ -106,11 +106,11 @@ final class AudioLevelTests: XCTestCase { |
| private func mic( |
| muted: Bool = false, capturing: Bool = true, inputDb: Float = -120, buffers: Int = 10, |
| - sentDb: Float = -120 |
| + sentDb: Float = -120, sending: Bool = true |
| ) -> MicSample { |
| MicSample( |
| muted: muted, capturing: capturing, inputPeak: level(db: inputDb), inputBuffers: buffers, |
| - sentPeak: level(db: sentDb)) |
| + sentPeak: level(db: sentDb), sending: sending) |
| } |
| func testTheMicrophoneWarningComesAfterFiveDeadSeconds() { |
| @@ -125,14 +125,57 @@ final class AudioLevelTests: XCTestCase { |
| XCTAssertFalse(watch.silent, "any sound on the input clears it") |
| } |
| - func testNoBuffersAtAllIsDeadToo() { |
| + func testBuffersStoppingIsDeadToo() { |
| + // Capture started, then nothing reached the hook at all: a device |
| + // that went away under the call. |
| var watch = MicWatch() |
| - for t in stride(from: 0.0, through: 5.2, by: 0.1) { |
| + watch.update(mic(inputDb: -20), now: 0) |
| + for t in stride(from: 0.1, through: 5.2, by: 0.1) { |
| watch.update(mic(inputDb: -20, buffers: 0), now: t) |
| } |
| XCTAssertTrue(watch.silent) |
| } |
| + func testAloneNeverWarns() { |
| + // Mac 0.4.6, 2026-10-03: alone in a call ("Waiting for others") no |
| + // connection sends, WebRTC never opens the microphone, and the hook |
| + // reads nothing — which is not a dead microphone. |
| + var never = MicWatch() |
| + var stale = MicWatch() |
| + for t in stride(from: 0.0, through: 30, by: 0.1) { |
| + never.update(mic(buffers: 0, sending: false), now: t) |
| + // And alone again after someone left: the module may still say it |
| + // is recording, the hook has had buffers before, and silence now. |
| + stale.update(mic(buffers: t < 1 ? 10 : 0, sending: false), now: t) |
| + XCTAssertFalse(never.silent, "alone, at \(t)s") |
| + XCTAssertFalse(stale.silent, "alone again, at \(t)s") |
| + } |
| + } |
| + |
| + func testWithSomeoneAndASilentMicrophoneItWarnsAfterFiveSeconds() { |
| + var watch = MicWatch() |
| + // Alone for a while: nothing. |
| + for t in stride(from: 0.0, through: 20, by: 0.1) { |
| + watch.update(mic(buffers: 0, sending: false), now: t) |
| + } |
| + // Someone joins; the connection is up before WebRTC has opened the |
| + // microphone, and that wait is not counted. |
| + for t in stride(from: 20.1, through: 27, by: 0.1) { |
| + watch.update(mic(buffers: 0, sending: true), now: t) |
| + XCTAssertFalse(watch.silent, "capture not started, at \(t)s") |
| + } |
| + // Capture starts, and is flat: five seconds later, the warning. |
| + for t in stride(from: 27.1, through: 32.0, by: 0.1) { |
| + watch.update(mic(sending: true), now: t) |
| + XCTAssertFalse(watch.silent, "at \(t)s") |
| + } |
| + watch.update(mic(sending: true), now: 32.2) |
| + XCTAssertTrue(watch.silent) |
| + // They leave: the warning goes at once. |
| + watch.update(mic(sending: false), now: 32.3) |
| + XCTAssertFalse(watch.silent) |
| + } |
| + |
| func testTheWarningAsksThePreSuppressorLevel() { |
| // The suppressor can take a quiet room to near silence; the input |
| // before it is what says whether the microphone hears anything. |
diff --git a/native/apple/Tests/CallsKitTests/CallBarFitTests.swift b/native/apple/Tests/CallsKitTests/CallBarFitTests.swift| new file mode 100644 |
| index 0000000..4c7cca4 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/CallBarFitTests.swift |
| @@ -0,0 +1,54 @@ |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +final class CallBarFitTests: XCTestCase { |
| + private let all = CallBarFit.Control.allCases |
| + |
| + func testEveryWindowTheAppAllowsHasRoomForEverything() { |
| + // 760 is the window's minimum (CallsMac.swift). |
| + for width in stride(from: 760.0, through: 3840, by: 1) { |
| + let fit = CallBarFit.fit(windowWidth: width, controls: all) |
| + XCTAssertEqual(fit.overflow, [], "at \(width)") |
| + XCTAssertEqual(fit.spacing, CallBarFit.roomy, "at \(width)") |
| + XCTAssertLessThanOrEqual(fit.width, CallBarFit.maxWidth) |
| + XCTAssertLessThanOrEqual(fit.width + 2 * CallBarFit.margin, width, "at \(width)") |
| + } |
| + } |
| + |
| + func testNarrowerItTightensThenMovesTheLeastUsedIntoMore() { |
| + var last = CallBarFit.fit(windowWidth: 760, controls: all) |
| + for width in stride(from: 759.0, through: 200, by: -1) { |
| + let fit = CallBarFit.fit(windowWidth: width, controls: all) |
| + // Never the controls that matter most. |
| + for control in all where control.essential { XCTAssertTrue(fit.shows(control), "\(control) at \(width)") } |
| + // Only ever more in ⋯ as it narrows, and in the bar's order. |
| + XCTAssertTrue(Set(last.overflow).isSubset(of: Set(fit.overflow)), "at \(width)") |
| + XCTAssertEqual(fit.overflow, all.filter { fit.overflow.contains($0) }) |
| + // Whatever is shown fits the pane, while the essentials can. |
| + let shown = all.filter(fit.shows) |
| + let needed = CallBarFit.needed(shown, fit.spacing, more: !fit.overflow.isEmpty) |
| + let essentials = CallBarFit.needed(all.filter(\.essential), CallBarFit.tight, more: true) |
| + if width - 2 * CallBarFit.margin >= essentials { |
| + XCTAssertLessThanOrEqual(needed, fit.width + 0.001, "at \(width)") |
| + } |
| + last = fit |
| + } |
| + XCTAssertEqual( |
| + CallBarFit.fit(windowWidth: 300, controls: all).overflow, [.share, .devices, .layout, .link]) |
| + } |
| + |
| + func testTheFirstToGoAreTheLinkThenLayout() { |
| + // ⋯ takes a button's place, so the first time anything moves, two do: |
| + // the link, and the layout (in the bar's order in ⋯). |
| + let tightAll = CallBarFit.needed(all, CallBarFit.tight, more: false) |
| + let fit = CallBarFit.fit(windowWidth: tightAll + 2 * CallBarFit.margin - 1, controls: all) |
| + XCTAssertEqual(fit.overflow, [.layout, .link]) |
| + // A guest has no link: the layout and Devices go first instead. |
| + let guest = all.filter { $0 != .link } |
| + let tightGuest = CallBarFit.needed(guest, CallBarFit.tight, more: false) |
| + XCTAssertEqual( |
| + CallBarFit.fit(windowWidth: tightGuest + 2 * CallBarFit.margin - 1, controls: guest).overflow, |
| + [.devices, .layout]) |
| + } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift| index 9bd944a..eaa8fea 100644 |
| --- a/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift |
| @@ -182,7 +182,7 @@ final class SpeakingGlowTests: XCTestCase { |
| // Your own tile, and muting puts it out. |
| let mic = { (muted: Bool, db: Float) in |
| - MicSample(muted: muted, capturing: true, inputPeak: self.level(db: db), inputBuffers: 10, sentPeak: self.level(db: db)) |
| + MicSample(muted: muted, capturing: true, inputPeak: self.level(db: db), inputBuffers: 10, sentPeak: self.level(db: db), sending: true) |
| } |
| for t in stride(from: 0.0, through: 0.5, by: 0.1) { |
| levels.updateMic(mic(false, -15), now: t, source: "", channels: false) |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index e82d837..2798437 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.4.7' |
| + MARKETING_VERSION: '0.4.8' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |
diff --git a/test/browser/native-webkit.mjs b/test/browser/native-webkit.mjs| new file mode 100644 |
| index 0000000..0a1bdc0 |
| --- /dev/null |
| +++ b/test/browser/native-webkit.mjs |
| @@ -0,0 +1,185 @@ |
| +// native-webkit.mjs — the Mac app in a call with Safari's engine. |
| +// |
| +// Driven by TestNativeMacWebKit (make native-webkit). 2026-10-03: Till, |
| +// on an iPhone in Safari, said a call with Paul on the Mac app "kept |
| +// cutting in and out", with nothing recorded. This is the nearest this |
| +// machine gets: Playwright's WebKit with its mock camera and microphone, |
| +// on macOS, against the shipping app core (CallsInterop). It is NOT iOS |
| +// Safari — no cellular radio, no background suspension, no audio session |
| +// interruptions, no Low Power Mode — and it cannot shape packet loss or |
| +// jitter (that needs root for dnctl/pfctl). What it can hold: |
| +// |
| +// - media both ways, and what was negotiated: the codecs each way and |
| +// Opus's in-band FEC; |
| +// - the app's sender giving way on a thin path (its bandwidth estimate |
| +// capped, as `uplink` does) while WebKit keeps decoding and hearing; |
| +// - the tab coming back after the system ended its camera and |
| +// microphone (iOS does, in the background): the room opens them again |
| +// and the app hears and sees it again (room.js comeBack). |
| +// |
| +// node test/browser/native-webkit.mjs <origin> <room> <binary> |
| +import { webkit } from 'playwright'; |
| +import { spawn } from 'node:child_process'; |
| +import { createInterface } from 'node:readline'; |
| +import assert from 'node:assert/strict'; |
| + |
| +const [origin, room, binary] = process.argv.slice(2); |
| +const browser = await webkit.launch({ headless: true }); |
| +const context = await browser.newContext({ permissions: ['camera', 'microphone'] }); |
| +await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]); |
| +const page = await context.newPage(); |
| +await page.addInitScript(() => { |
| + const Native = window.RTCPeerConnection; |
| + window.__pcs = new Set(); |
| + window.RTCPeerConnection = class extends Native { |
| + constructor(...args) { super(...args); window.__pcs.add(this); } |
| + }; |
| + // The streams the room opened, so the test can end their tracks as the |
| + // system would. |
| + window.__gum = []; |
| + const gum = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); |
| + navigator.mediaDevices.getUserMedia = async (c) => { |
| + const s = await gum(c); |
| + window.__gum.push(s); |
| + return s; |
| + }; |
| +}); |
| + |
| +let native; |
| +let sample = null; |
| +let exited = false; |
| +const wait = async (predicate, label, seconds = 40) => { |
| + const until = Date.now() + seconds * 1000; |
| + while (Date.now() < until) { |
| + if (exited) throw new Error('native executable exited'); |
| + if (await predicate()) return; |
| + await new Promise((r) => setTimeout(r, 250)); |
| + } |
| + throw new Error(label + ': ' + JSON.stringify(sample && sample.stats)); |
| +}; |
| + |
| +// What WebKit is receiving from the app and sending to it, off its own |
| +// connection's stats. |
| +const webkitStats = () => page.evaluate(async () => { |
| + const out = { videoIn: null, audioIn: null, videoOut: null, audioOut: null, fec: null, pair: null }; |
| + for (const pc of window.__pcs) { |
| + if (pc.connectionState !== 'connected') continue; |
| + const stats = await pc.getStats(); |
| + const codecs = {}; |
| + stats.forEach((r) => { if (r.type === 'codec') codecs[r.id] = r; }); |
| + stats.forEach((r) => { |
| + const codec = r.codecId && codecs[r.codecId] ? codecs[r.codecId].mimeType + (codecs[r.codecId].sdpFmtpLine ? ' ' + codecs[r.codecId].sdpFmtpLine : '') : ''; |
| + if (r.type === 'inbound-rtp' && r.kind === 'video') { |
| + out.videoIn = { codec, framesDecoded: r.framesDecoded || 0, width: r.frameWidth || 0, height: r.frameHeight || 0, |
| + fps: r.framesPerSecond || 0, bytes: r.bytesReceived || 0, lost: r.packetsLost || 0, freezes: r.freezeCount || 0 }; |
| + } |
| + if (r.type === 'inbound-rtp' && r.kind === 'audio') { |
| + out.audioIn = { codec, bytes: r.bytesReceived || 0, energy: r.totalAudioEnergy || 0, concealed: r.concealedSamples || 0, |
| + samples: r.totalSamplesReceived || 0, lost: r.packetsLost || 0, fecRecovered: r.fecPacketsReceived || 0 }; |
| + } |
| + if (r.type === 'outbound-rtp' && r.kind === 'video') out.videoOut = { codec, frames: r.framesEncoded || 0 }; |
| + if (r.type === 'outbound-rtp' && r.kind === 'audio') out.audioOut = { codec, bytes: r.bytesSent || 0 }; |
| + if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') out.pair = { rtt: r.currentRoundTripTime }; |
| + }); |
| + const sdp = (pc.remoteDescription && pc.remoteDescription.sdp) || ''; |
| + const opus = /a=rtpmap:(\d+) opus\/48000/i.exec(sdp); |
| + if (opus) { |
| + const fmtp = new RegExp('a=fmtp:' + opus[1] + ' ([^\\r\\n]*)').exec(sdp); |
| + out.fec = fmtp ? fmtp[1] : ''; |
| + } |
| + break; |
| + } |
| + return out; |
| +}); |
| + |
| +try { |
| + page.on('pageerror', (e) => console.log('page error', String(e))); |
| + await page.goto(`${origin}/m/${room}`); |
| + await page.waitForFunction(() => !!window.meet); |
| + native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); |
| + native.on('exit', () => { exited = true; }); |
| + native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n'); |
| + createInterface({ input: native.stdout }).on('line', (text) => { |
| + try { |
| + const value = JSON.parse(text); |
| + if (value.stats) sample = value; |
| + } catch {} |
| + }); |
| + native.stderr.resume(); |
| + // WebKit wants a gesture before a remote <video> may play with sound: |
| + // the room's "click anywhere" is that gesture. |
| + const nudge = setInterval(() => { page.mouse.click(5, 300).catch(() => {}); }, 2000); |
| + |
| + await wait(() => sample && sample.stats.framesDecoded > 15 && sample.stats.audioBytesReceived > 0, |
| + 'the app decodes WebKit video and receives its audio'); |
| + console.log('PASS the app decodes WebKit’s video and receives its audio'); |
| + await wait(async () => { |
| + const s = await webkitStats(); |
| + return s.videoIn && s.videoIn.framesDecoded > 15 && s.audioIn && s.audioIn.bytes > 0; |
| + }, 'WebKit decodes the app’s video and receives its audio'); |
| + let s = await webkitStats(); |
| + console.log('PASS WebKit decodes the app’s video and receives its audio'); |
| + console.log('negotiated ' + JSON.stringify({ |
| + appToWebKitVideo: s.videoIn.codec, appToWebKitAudio: s.audioIn.codec, |
| + webKitToAppVideo: s.videoOut && s.videoOut.codec, webKitToAppAudio: s.audioOut && s.audioOut.codec, |
| + opusFmtpFromApp: s.fec, |
| + received: { width: s.videoIn.width, height: s.videoIn.height, fps: s.videoIn.fps }, |
| + })); |
| + assert.match(s.fec || '', /useinbandfec=1/, 'the app offers or answers Opus with in-band FEC'); |
| + console.log('PASS Opus in-band FEC is negotiated from the app'); |
| + |
| + // A thin path from the app: its bandwidth estimate capped at 300 kbit/s, |
| + // as a phone on a poor cellular link would hold it. The sender should |
| + // give way (resolution or rate) and the sound should carry on. |
| + native.stdin.write('uplink 300000\n'); |
| + await new Promise((r) => setTimeout(r, 12000)); |
| + const before = await webkitStats(); |
| + await new Promise((r) => setTimeout(r, 5000)); |
| + const after = await webkitStats(); |
| + const kbps = ((after.videoIn.bytes - before.videoIn.bytes) * 8) / 5000; |
| + const decoded = after.videoIn.framesDecoded - before.videoIn.framesDecoded; |
| + const audioGrew = after.audioIn.bytes - before.audioIn.bytes; |
| + console.log('thin path ' + JSON.stringify({ videoKbps: Math.round(kbps), framesIn5s: decoded, |
| + width: after.videoIn.width, height: after.videoIn.height, audioBytesIn5s: audioGrew, |
| + concealed: after.audioIn.concealed - before.audioIn.concealed, freezes: after.videoIn.freezes })); |
| + assert.ok(kbps < 400, 'the app’s video gave way to the 300 kbit/s cap (' + Math.round(kbps) + ' kbit/s)'); |
| + assert.ok(decoded > 10, 'WebKit kept decoding video on the thin path (' + decoded + ' frames in 5 s)'); |
| + assert.ok(audioGrew > 5000, 'and kept receiving sound (' + audioGrew + ' bytes in 5 s)'); |
| + console.log('PASS on a thin path the app gives way and WebKit keeps the picture and the sound'); |
| + native.stdin.write('uplink 0\n'); |
| + |
| + // The system ending the tab's camera and microphone, as iOS does with |
| + // Safari in the background, and the tab coming back to the front. |
| + const appBefore = { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded }; |
| + await page.evaluate(() => { |
| + for (const s of window.__gum) for (const t of s.getTracks()) t.stop(); |
| + let shown = 'hidden'; |
| + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => shown }); |
| + Object.defineProperty(document, 'hidden', { configurable: true, get: () => shown === 'hidden' }); |
| + document.dispatchEvent(new Event('visibilitychange')); |
| + window.__show = () => { shown = 'visible'; document.dispatchEvent(new Event('visibilitychange')); }; |
| + }); |
| + await new Promise((r) => setTimeout(r, 3000)); |
| + const stalled = { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded }; |
| + await page.evaluate(() => window.__show()); |
| + await wait(async () => { |
| + const local = await page.evaluate(() => window.meet.local()); |
| + return local.length >= 2 && local.every((t) => t.state === 'live'); |
| + }, 'the room opens the camera and microphone again when the tab is back', 15); |
| + console.log('PASS the room opens the camera and microphone again when the tab is back'); |
| + const reopened = { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded }; |
| + await wait(() => sample.stats.audioBytesReceived > reopened.audio + 4000 && sample.stats.framesDecoded > reopened.frames + 15, |
| + 'the app hears and sees WebKit again', 20); |
| + console.log('PASS the app hears and sees WebKit again ' + JSON.stringify({ appBefore, stalled, reopened, |
| + after: { audio: sample.stats.audioBytesReceived, frames: sample.stats.framesDecoded } })); |
| + |
| + clearInterval(nudge); |
| + native.stdin.write('leave\n'); |
| + await new Promise((r) => setTimeout(r, 1000)); |
| +} catch (err) { |
| + console.log('FAIL ' + (err && err.message ? err.message : String(err))); |
| + process.exitCode = 1; |
| +} finally { |
| + if (native && !exited) native.kill(); |
| + await browser.close(); |
| +} |
diff --git a/test/browser/room-hearing.test.mjs b/test/browser/room-hearing.test.mjs| index 74d26fc..8a515b8 100644 |
| --- a/test/browser/room-hearing.test.mjs |
| +++ b/test/browser/room-hearing.test.mjs |
| @@ -11,6 +11,8 @@ |
| // audioSample one stats report read against the last: the level, and |
| // whether it was sound at all |
| // judgeSilence the five-second rule, and what it refuses to count |
| +// selfMicWatched whether our own microphone may be judged at all: |
| +// never alone, never muted |
| // judgeSpeaking the speaking highlight's hysteresis |
| // hearingShown muted, then blocked, then no sound, then live sound |
| // |
| @@ -56,6 +58,7 @@ const build = (name) => |
| const audioSample = build('audioSample'); |
| const judgeSilence = build('judgeSilence'); |
| +const selfMicWatched = build('selfMicWatched'); |
| const judgeSpeaking = build('judgeSpeaking'); |
| const hearingShown = build('hearingShown'); |
| const remoteNotice = build('remoteNotice'); |
| @@ -117,6 +120,29 @@ test('judgeSilence: five flat seconds of an arriving, eligible stream, and not a |
| assert.equal(judgeSilence(rec, t0 + 10400, true, true, false), true); |
| }); |
| +test('our own microphone: alone never warns; with someone, five silent seconds do', () => { |
| + // Mac 0.4.6, 2026-10-03: alone in a call, nothing is sent, and the |
| + // warning said a working microphone was dead. Same rule in the browser. |
| + const rec = hearingRecord(); |
| + const t0 = 1000000; |
| + for (let t = 0; t <= 30000; t += 200) { |
| + judgeSilence(rec, t0 + t, selfMicWatched(true, false, false), true, false); |
| + assert.equal(rec.silent, false, 'alone, at ' + t + ' ms'); |
| + } |
| + // Muted, with someone: the Muted state says it, not this (0.3.6). |
| + for (let t = 30000; t <= 40000; t += 200) { |
| + judgeSilence(rec, t0 + t, selfMicWatched(true, true, true), true, false); |
| + assert.equal(rec.silent, false, 'muted, at ' + t + ' ms'); |
| + } |
| + // Unmuted, someone connected, flat: the warning, five seconds on. |
| + const start = t0 + 40200; |
| + assert.equal(judgeSilence(rec, start, selfMicWatched(true, false, true), true, false), false); |
| + assert.equal(judgeSilence(rec, start + 4999, selfMicWatched(true, false, true), true, false), false); |
| + assert.equal(judgeSilence(rec, start + 5000, selfMicWatched(true, false, true), true, false), true); |
| + // They leave: gone at once. |
| + assert.equal(judgeSilence(rec, start + 5200, selfMicWatched(true, false, false), true, false), false); |
| +}); |
| + |
| test('judgeSilence counts nothing that is not the sender’s silence', () => { |
| const t0 = 1000000; |
| for (const [eligible, arriving, why] of [ |
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs| index d1c4d4f..2df980a 100644 |
| --- a/test/browser/room-lane.test.mjs |
| +++ b/test/browser/room-lane.test.mjs |
| @@ -1255,7 +1255,7 @@ const cam = cameraBox(CAMERA_STALL_POLLS, CAMERA_REPAIRS, switching); |
| function cameraTx(opts) { |
| const o = opts || {}; |
| const calls = []; |
| - const track = { kind: 'video', readyState: o.readyState || 'live' }; |
| + const track = { kind: 'video', readyState: o.readyState || 'live', muted: !!o.muted, enabled: o.enabled !== false }; |
| const tx = { |
| mid: o.mid === undefined ? '1' : o.mid, |
| currentDirection: o.direction === undefined ? 'sendrecv' : o.direction, |
| @@ -1357,6 +1357,26 @@ test('nothing is repaired on a connection that is not sending a camera', () => { |
| } |
| }); |
| +test('a camera the system is holding is never repaired or reset', () => { |
| + // iOS mutes the camera track, live, while Safari is in the background |
| + // or the screen is locked (2026-10-03, "kept cutting in and out"): no |
| + // frames, and nothing wrong with the encoder. Nor is a camera the |
| + // person turned off. Either way, never a reset of the connection. |
| + for (const opts of [{ muted: true }, { enabled: false }]) { |
| + const c = cameraTx(opts); |
| + const entry = watching(); |
| + const said = verdicts(entry, c.tx, [100, 100, 100, 100, 100, 100, 100, 100]); |
| + assert.deepEqual(said, ['', '', '', '', '', '', '', ''], JSON.stringify(opts)); |
| + assert.equal(entry.cameraRepairs, 0); |
| + } |
| + // Given back, it is watched again from scratch. |
| + const c = cameraTx({ muted: true }); |
| + const entry = watching(); |
| + verdicts(entry, c.tx, [100, 100, 100]); |
| + c.track.muted = false; |
| + assert.deepEqual(verdicts(entry, c.tx, [100, 110, 120]), ['', '', '']); |
| +}); |
| + |
| test('a stall is forgotten when the camera stops being watchable', () => { |
| // Half a stall, then no report at all — a connection that stopped |
| // sending video. The count starts again rather than tipping over into |
| @@ -1431,3 +1451,17 @@ test('a sender that cannot replace its track is left alone', async () => { |
| await assert.doesNotReject(() => cam.repairCamera(null)); |
| await assert.doesNotReject(() => cam.repairCamera({ sender: null })); |
| }); |
| + |
| +test('lostKinds: only a kind we had, every track of it ended', () => { |
| + // The tab in front again (comeBack): iOS can end the camera and the |
| + // microphone while Safari is in the background, and those are opened |
| + // again; a kind that is merely muted, or never was, is left alone. |
| + const lostKinds = new Function(lift(src, 'lostKinds') + '; return lostKinds;')(); |
| + const live = { readyState: 'live' }; |
| + const ended = { readyState: 'ended' }; |
| + assert.deepEqual(lostKinds([ended], [ended]), ['audio', 'video']); |
| + assert.deepEqual(lostKinds([live], [ended]), ['video']); |
| + assert.deepEqual(lostKinds([ended, live], []), [], 'one live track is enough, and no video is not lost video'); |
| + assert.deepEqual(lostKinds([], []), []); |
| + assert.deepEqual(lostKinds(undefined, undefined), []); |
| +}); |
diff --git a/test/browser/room-window.test.mjs b/test/browser/room-window.test.mjs| index 8ccbcbe..2237ab4 100644 |
| --- a/test/browser/room-window.test.mjs |
| +++ b/test/browser/room-window.test.mjs |
| @@ -172,3 +172,19 @@ test('every state the title pill carried is a notice now (2026-10-02)', () => { |
| assert.ok(/This meeting has ended/.test(roomJS), 'the call being over is said'); |
| assert.ok(!/callState|call-state/.test(windowJS), 'and no pill is drawn'); |
| }); |
| + |
| +// 2026-10-03, Till on an iPhone: "kept cutting in and out". Safari there |
| +// stops the camera, the microphone and the sound with the tab in the |
| +// background or the screen locked, so the room says so, once a session. |
| +test('appleTouch: iPhone and iPad, in any browser there, and nothing else', () => { |
| + const appleTouch = new Function(lift(windowJS, 'appleTouch') + '; return appleTouch;')(); |
| + const iphone = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1'; |
| + const crios = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/140.0 Mobile/15E148 Safari/604.1'; |
| + const mac = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15'; |
| + assert.equal(appleTouch({ platform: 'iPhone', userAgent: iphone, maxTouchPoints: 5 }), true); |
| + assert.equal(appleTouch({ platform: 'iPhone', userAgent: crios, maxTouchPoints: 5 }), true, 'Chrome on iPhone is Safari underneath'); |
| + assert.equal(appleTouch({ platform: 'MacIntel', userAgent: mac, maxTouchPoints: 5 }), true, 'an iPad asking for the desktop site'); |
| + assert.equal(appleTouch({ platform: 'MacIntel', userAgent: mac, maxTouchPoints: 0 }), false, 'a Mac'); |
| + assert.equal(appleTouch({ platform: 'Linux armv8l', userAgent: 'Mozilla/5.0 (Linux; Android 15) Chrome/140 Mobile', maxTouchPoints: 5 }), false); |
| + assert.equal(appleTouch(undefined), false); |
| +}); |