oficina / meet Public

Merge fix/alone-warning-bar-safari: no mic warning alone, a bar that always fits, Safari's camera and tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b81eabad577fd40a68e170e3b8883aafd92f716e parent de04e2dparent 9b32a40
23 files changed, +979 −53
  • Makefile +12 −1
  • docs/superpowers/specs/2026-09-07-meet-design.md +4 −1
  • internal/calls/static/call-window.js +31 −0
  • internal/calls/static/room.js +59 −4
  • internal/calls/templates/room.html +5 −0
  • internal/callstest/native_browser_test.go +59 −0
  • native/README.md +4 −1
  • native/apple/Sources/CallsInterop/BarLayoutHarness.swift +170 −0
  • native/apple/Sources/CallsInterop/Interop.swift +3 −0
  • native/apple/Sources/CallsKit/AudioLevelRules.swift +24 −2
  • native/apple/Sources/CallsKit/Call.swift +8 −1
  • native/apple/Sources/CallsKit/CallBarFit.swift +73 −0
  • native/apple/Sources/CallsKit/CallFixtures.swift +35 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +107 −27
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +20 −9
  • native/apple/Tests/CallsKitTests/AudioLevelTests.swift +47 −4
  • native/apple/Tests/CallsKitTests/CallBarFitTests.swift +54 −0
  • native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift +1 −1
  • native/ios/project.yml +1 −1
  • test/browser/native-webkit.mjs +185 −0
  • test/browser/room-hearing.test.mjs +26 −0
  • test/browser/room-lane.test.mjs +35 −1
  • test/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	(cached)
?   	amadan.net/oficina/calls/cmd/calls	[no test files]
ok  	amadan.net/oficina/calls/internal/calls	1.576s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	8.637s
?   	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.166s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	27.115s
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);
+});