Merge origin/main (fix/alone-warning-bar-safari) into feat/quality-telemetry
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
diff --git a/Makefile b/Makefile| index 01b91f6..278825d 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -179,7 +179,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 5e665b1..4904922 100644 |
| --- a/docs/superpowers/specs/2026-09-07-meet-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-meet-design.md |
| @@ -2362,7 +2362,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 279221c..c1003cf 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -5908,6 +5908,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. |
| @@ -6293,7 +6304,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; |
| @@ -6305,7 +6319,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; |
| @@ -7233,9 +7247,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(); |
| @@ -7433,7 +7480,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 0f6b335..0665fdf 100644 |
| --- a/internal/calls/templates/room.html |
| +++ b/internal/calls/templates/room.html |
| @@ -75,6 +75,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 01a4e08..e51c3d3 100644 |
| --- a/internal/callstest/native_browser_test.go |
| +++ b/internal/callstest/native_browser_test.go |
| @@ -903,3 +903,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 ab7c49f..213e279 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 a4ecd42..5a44ce5 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 98d154e..501feb0 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -195,6 +195,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 |
| @@ -646,6 +651,7 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| reportedNoSound = [] |
| audioBytes = [:] |
| audioArriving = [:] |
| + sendingAudio = false |
| } |
| private func resetConnecting() { |
| @@ -674,7 +680,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( |
| @@ -701,6 +707,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 964c20a..2a4c450 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 |
| @@ -344,11 +346,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" |
| @@ -361,6 +371,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" |
| @@ -373,27 +384,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, |
| @@ -401,14 +415,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() } |
| @@ -438,9 +458,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); |
| +}); |