oficina / meet Public

Merge origin/main (24e2824, Calls 0.4.3 screen-share fixes) into feat/warm-rim-speaking

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 70fd94a482dbcfcaf957547a4daf9c715de8219d parent bd0615cparent 24e2824
15 files changed, +324 −50
  • docs/superpowers/specs/2026-09-07-meet-design.md +8 −6
  • internal/calls/static/calls.css +37 −0
  • internal/calls/static/room.js +14 −3
  • internal/calls/templates/room.html +9 −0
  • native/README.md +8 −5
  • native/apple/Sources/CallsKit/Call.swift +5 −2
  • native/apple/Sources/CallsKit/CallActionStyle.swift +12 −4
  • native/apple/Sources/CallsKit/CallChrome.swift +60 −2
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +9 −1
  • native/apple/Sources/CallsKit/Media.swift +19 −9
  • native/apple/Sources/CallsKit/ScreenWatchdog.swift +34 −7
  • native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift +77 −7
  • native/ios/Sources/PhoneFullScreenCall.swift +9 −2
  • native/ios/project.yml +1 −1
  • test/browser/call.mjs +22 −1
diff --git a/docs/superpowers/specs/2026-09-07-meet-design.md b/docs/superpowers/specs/2026-09-07-meet-design.md
index 0457a9b..58989e6 100644
--- a/docs/superpowers/specs/2026-09-07-meet-design.md
+++ b/docs/superpowers/specs/2026-09-07-meet-design.md
@@ -2403,9 +2403,9 @@ own screen, and the receiver says when a screen has stopped for it.
- **The sharer's watchdog**, once a second while sharing: a capture that
reports nothing for three seconds is started again on the same content
(5, 10, 20… up to 60 seconds apart); a connection whose screen takes
- frames in and encodes or sends none for three seconds gets, three seconds
- apart, a keyframe (the encoding switched off and on), a fresh encoder
- (the screen sent a third smaller, for the rest of that share), and a new
+ frames in and encodes or sends none for five seconds, while WebRTC does
+ not name the link as what limits it, gets, three seconds apart, a keyframe (the encoding switched off and on), a fresh encoder
+ (the screen sent a third smaller, for thirty seconds), and a new
transceiver (what stopping and sharing again did, renegotiated as §5.4
does), then rests thirty seconds. Every step is logged (`os_log`
subsystem `net.amadan.oficina.calls`, category `screen`) and kept for
@@ -2419,9 +2419,11 @@ own screen, and the receiver says when a screen has stopped for it.
distinct well-formed peer ids, validated exactly as `noSoundFrom` is, and
re-encoded after it (`muted`, `cameraOff`, `audioBlocked`, `noSoundFrom`,
`screenStalled`). The raw payload cap is 2 KB, room for both lists full.
-- **A sharer named in anyone's report recovers** as for its own stall — a
- keyframe first, four seconds between steps, because the report is a few
- seconds old when it lands — and says so, quietly, while it lasts:
+- **A sharer named in anyone's report recovers**: a keyframe for that
+ connection, four seconds apart (the report is a few seconds old when it
+ lands), three times and then a rest; the fresh encoder and the new
+ transceiver only if its own screen has stopped leaving as well — a
+ report about a slow link is not the encoder's fault. It says so, quietly, while it lasts:
"Vicky's view of your screen stalled — recovering". The browser, which
has no fresh encoder or transceiver to offer short of a new share, sends
keyframes only.
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 1353d87..f487799 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -730,3 +730,40 @@
@container (max-width: 9rem) {
.calls-tile-off .icon { inline-size: 1.25rem; block-size: 1.25rem; }
}
+
+/* The sharing notice (2026-09-28): a dark pill above the stage with its
+ own Stop sharing button, and the recovery line under it. No edge: it
+ is told from the page by its tone, as the native notices are. */
+.calls-share-notice:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--rst-sp-1);
+ margin-block: 0 var(--rst-sp-2);
+}
+.calls-share-notice-row {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--rst-sp-2);
+ margin: 0;
+ padding: 6px 6px 6px 14px;
+ border-radius: 999px;
+ background: rgba(20, 20, 20, 0.88);
+ color: #ffd27a;
+ font-weight: 600;
+}
+.calls-share-notice-row svg { inline-size: 1.1em; block-size: 1.1em; }
+.calls-share-stop {
+ min-block-size: 28px;
+ padding: 2px 12px;
+ border: 0;
+ border-radius: 999px;
+ background: #fff;
+ color: #111;
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+}
+.calls-share-stop:hover { background: #e8e8e8; }
+.calls-share-stop:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
+.calls-share-recovery { margin: 0; font-size: 0.9em; color: var(--rst-text); }
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 43fed76..b785cc8 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -67,6 +67,10 @@
const muteState = $('mute-state');
const cameraState = $('camera-state');
const shareState = $('share-state');
+ // The sharing notice and its own Stop sharing button (2026-09-28).
+ const shareNotice = $('share-notice');
+ const shareStop = $('share-stop');
+ const shareRecoveryEl = $('share-recovery');
// §15.19: the tile marks, rendered by the server so they are the
// vendored badge and the app's own icons rather than a copy of either,
// and the polite region that says a change out loud. #status is no
@@ -4121,6 +4125,12 @@
if (!shareButton) return;
shareButton.setAttribute('aria-pressed', String(!!share));
if (shareState) shareState.textContent = share ? 'On' : 'Off';
+ if (shareNotice) shareNotice.hidden = !share;
+ if (shareRecoveryEl) {
+ const text = share ? shareRecovery : '';
+ shareRecoveryEl.textContent = text;
+ shareRecoveryEl.hidden = !text;
+ }
}
// sharingPeers: the peers holding a share tile, for the gate.
@@ -4368,7 +4378,7 @@
const text = recoveringWords(names_);
if (text !== shareRecovery) {
shareRecovery = text;
- renderStatus();
+ renderShare();
}
}
@@ -5184,8 +5194,7 @@
say('You are the only one here.');
return;
}
- // §15.21: quiet, and only while it lasts.
- say(share ? shareRecovery : '');
+ say('');
}
function isMember() {
@@ -5667,6 +5676,8 @@
// a named function with a catch of its own — the same shape as the
// pageshow handler's.
if (shareButton) {
+ // The notice's own way out: the same stop as the Share button's.
+ if (shareStop) shareStop.addEventListener('click', () => stopSharing());
shareButton.addEventListener('click', () => {
if (share) {
stopSharing();
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index f8d8628..8b638c2 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -27,6 +27,15 @@
it stood between c80a506 and a6c2ac8. */}}
<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p>{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}}</noscript>
<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden>
+ {{/* While this tab shares its screen: said in words, with the way out
+ beside it (Paul, 2026-09-28: "I would expect this notification to
+ have a 'Stop sharing' button inline"), and, under it, whose view of
+ the screen is being recovered (spec §15.21). In the flow above the
+ stage, so it never covers a tile and never fades. */}}
+ <div id="share-notice" class="calls-share-notice" hidden>
+ <p class="calls-share-notice-row">{{icon "monitor"}}<span>You are sharing your screen</span><button type="button" id="share-stop" class="calls-share-stop" aria-label="Stop sharing your screen">Stop sharing</button></p>
+ <p id="share-recovery" class="calls-share-recovery" role="status" hidden></p>
+ </div>
<div id="call-workspace" estilo-call-workspace="sidebar">
<div class="calls-stage">
<div id="tiles" class="calls-grid"></div>
diff --git a/native/README.md b/native/README.md
index 8d2d46a..2f6f916 100644
--- a/native/README.md
+++ b/native/README.md
@@ -502,7 +502,9 @@ transceiver in its own stream, exactly as the browser sends one, announced with
`share {on:true}`; stopping stops the transceiver and renegotiates, so the far
side's track ends rather than freezing, and says `share {on:false}`. The menu
bar's stop control and a shared window closing end it the same way as the
-button. Frames are capped at 1920 on the long side and 30 a second.
+button, and so does **Stop sharing** in the "You are sharing your screen"
+notice itself (0.4.3; the iPhone's and the browser's notices have it too), which
+stays on screen while the controls fade. Frames are capped at 1920 on the long side and 30 a second.
**Optimise shared screen for** (in the device panel, remembered, applied live
to a share in progress) decides what gives way when the uplink or the encoder
@@ -532,11 +534,12 @@ parameters.
after Paul's "my screen froze on her end … I had to stop and re-share", 28 Sep).
The sharer's watchdog (`ScreenWatchdog`) looks once a second: a capture that
reports nothing for three seconds is restarted on the same content; a connection
-that takes frames in and encodes or sends none for three seconds gets a keyframe,
-then a fresh encoder (the screen a third smaller), then a new transceiver, three
-seconds apart. A receiver — app or browser — whose view of someone's screen has
+that takes frames in and encodes or sends none for five seconds (past its first
+ten, and not held back by the link) gets a keyframe, then a fresh encoder (the screen a third
+smaller for thirty seconds), then a new transceiver, three seconds apart. A receiver — app or browser — whose view of someone's screen has
decoded nothing for four seconds says so in its `media` report
-(`screenStalled`), the sharer runs the same steps for that connection, and shows
+(`screenStalled`), the sharer sends that connection keyframes (and the fuller
+steps only if its own screen has stopped leaving too), and shows
"Vicky's view of your screen stalled — recovering" while it does. Each step is in
`log show --predicate 'subsystem == "net.amadan.oficina.calls" && category ==
"screen"'` and in **Copy call diagnostics** (the panel's ⓘ; Test audio's is still
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 1fe3512..80d24aa 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -801,7 +801,9 @@ private final class Events: NSObject, BridgeListenerProtocol {
let stalledBy = Set(
(state?.peers ?? []).filter { $0.peer != me && ($0.media?.screenStalled ?? []).contains(me) }.map(\.peer))
let sending = reports.mapValues {
- ScreenSendCounts(encoded: $0.framesEncoded, sent: $0.framesSent, keyframes: $0.keyFramesEncoded)
+ ScreenSendCounts(
+ encoded: $0.framesEncoded, sent: $0.framesSent, keyframes: $0.keyFramesEncoded,
+ bandwidthLimited: $0.limitation == "bandwidth")
}
var actions = screenWatchdog.observe(
at: ProcessInfo.processInfo.systemUptime, capture: capture, sending: sending, stalledBy: stalledBy)
@@ -880,7 +882,8 @@ private final class Events: NSObject, BridgeListenerProtocol {
sharingScreen = false
ScreenLog.info("sharing stopped")
endScreenWatch()
- media.stopScreen()
+ // What was never built has no screen to stop.
+ built?.stopScreen()
send(type: "share", to: "", gen: 0, payload: json(["on": false]) ?? "")
}
diff --git a/native/apple/Sources/CallsKit/CallActionStyle.swift b/native/apple/Sources/CallsKit/CallActionStyle.swift
index bb1dbcf..9e36e1a 100644
--- a/native/apple/Sources/CallsKit/CallActionStyle.swift
+++ b/native/apple/Sources/CallsKit/CallActionStyle.swift
@@ -108,12 +108,20 @@ extension EnvironmentValues {
/// and iOS 26, the ultra-thin material before — with black at
/// `CallOverlay.scrim` on it. Only the background is made dark, so a sheet
/// or popover the content opens keeps the person's own appearance.
+///
+/// `rim: false` leaves Liquid Glass out even where the system has it: the
+/// glass lights its own edge, a faint light hairline that a small pill
+/// over a dark picture shows plainly (the sharing notice, 2026-09-28).
public struct CallOverlaySurface<S: InsettableShape>: ViewModifier {
let shape: S
+ let rim: Bool
@Environment(\.callOverlayGlass) private var glass
- public init(_ shape: S) { self.shape = shape }
+ public init(_ shape: S, rim: Bool = true) {
+ self.shape = shape
+ self.rim = rim
+ }
public func body(content: Content) -> some View {
- if glass, #available(macOS 26, iOS 26, *) {
+ if glass, rim, #available(macOS 26, iOS 26, *) {
content
.background(shape.fill(Color.black.opacity(CallOverlay.scrim)))
.glassEffect(.regular, in: shape)
@@ -132,8 +140,8 @@ public struct CallOverlaySurface<S: InsettableShape>: ViewModifier {
extension View {
/// See `CallOverlaySurface`.
- public func callOverlaySurface<S: InsettableShape>(_ shape: S) -> some View {
- modifier(CallOverlaySurface(shape))
+ public func callOverlaySurface<S: InsettableShape>(_ shape: S, rim: Bool = true) -> some View {
+ modifier(CallOverlaySurface(shape, rim: rim))
}
}
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift
index 1fa86b9..bb3ea17 100644
--- a/native/apple/Sources/CallsKit/CallChrome.swift
+++ b/native/apple/Sources/CallsKit/CallChrome.swift
@@ -340,8 +340,12 @@ public struct CallNotice: Identifiable, Equatable, Sendable {
case ended
/// Something went wrong that the person should know about.
case problem
- /// This app is sharing its screen: said in words for as long as it is.
+ /// This app is sharing its screen: said in words for as long as it is,
+ /// with a Stop sharing button of its own (`action`).
case sharing
+ /// Someone's view of this app's screen stalled and the watchdog is
+ /// putting it right (spec §15.21). Quiet, and under the sharing notice.
+ case screenRecovering
/// Relay unavailable: direct connections only.
case floor
}
@@ -357,6 +361,14 @@ public struct CallNotice: Identifiable, Equatable, Sendable {
/// that ends on its own (reconnecting, sharing) go when it does; a
/// problem or an ended call is the person's to put away.
public var dismissible: Bool { kind == .problem }
+
+ /// What the notice's own button does, if it has one.
+ public enum Action: Equatable, Sendable {
+ /// Paul, 2026-09-28: "I would expect this notification to have a
+ /// 'Stop sharing' button inline." The same stop as the bar's toggle.
+ case stopSharing
+ }
+ public var action: Action? { kind == .sharing ? .stopSharing : nil }
}
public enum CallNotices {
@@ -379,7 +391,7 @@ public enum CallNotices {
if sharingScreen {
out.append(CallNotice(kind: .sharing, text: "You are sharing your screen"))
// Quiet: said while the watchdog puts it right, and gone after.
- if !screenRecovery.isEmpty { out.append(CallNotice(kind: .sharing, text: screenRecovery)) }
+ if !screenRecovery.isEmpty { out.append(CallNotice(kind: .screenRecovering, text: screenRecovery)) }
} else if !screenNote.isEmpty {
out.append(CallNotice(kind: .problem, text: screenNote))
}
@@ -393,6 +405,52 @@ public enum CallNotices {
}
}
+/// A notice's own button (`CallNotice.action`): a compact light capsule
+/// that reads on the dark glass, reachable from the keyboard, where
+/// Return as well as Space presses it.
+public struct NoticeActionButton: View {
+ let action: CallNotice.Action
+ let perform: () -> Void
+ @FocusState private var focused: Bool
+
+ public init(_ action: CallNotice.Action, perform: @escaping () -> Void) {
+ self.action = action
+ self.perform = perform
+ }
+
+ public var body: some View {
+ switch action {
+ case .stopSharing:
+ Button(action: perform) {
+ Text("Stop sharing").font(.callout.weight(.semibold))
+ .padding(.horizontal, 12).padding(.vertical, 4)
+ .foregroundStyle(Color.black)
+ .background(Capsule().fill(Color.white.opacity(focused ? 1 : 0.92)))
+ .overlay(Capsule().strokeBorder(Color.white, lineWidth: focused ? 2 : 0).padding(-3))
+ .contentShape(Capsule())
+ }
+ .buttonStyle(.plain)
+ .focusable()
+ .focused($focused)
+ .onKeyPress(.return) {
+ perform()
+ return .handled
+ }
+ .accessibilityLabel("Stop sharing your screen")
+ .accessibilityIdentifier("stopSharingInline")
+ }
+ }
+}
+
+extension Call {
+ /// Runs a notice's own action (see `CallNotice.action`).
+ public func perform(_ action: CallNotice.Action) {
+ switch action {
+ case .stopSharing: stopSharingScreen()
+ }
+ }
+}
+
// MARK: - Grid
/// How a grid of tiles fills a stage without scrolling.
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
index 5887419..1e79c08 100644
--- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift
+++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
@@ -368,6 +368,8 @@
case .sharing:
Label(notice.text, systemImage: "rectangle.on.rectangle")
.foregroundStyle(CallOverlay.alertText).bold()
+ case .screenRecovering:
+ Label(notice.text, systemImage: "arrow.clockwise")
case .reconnecting, .waitingToJoin:
// Drawn rather than AppKit's spinner, which stays dark-on-dark
// on this surface in a light-appearance app.
@@ -387,6 +389,9 @@
case .problem, .floor:
Label(notice.text, systemImage: "exclamationmark.triangle.fill")
}
+ if let action = notice.action {
+ NoticeActionButton(action) { call.perform(action) }
+ }
if notice.dismissible {
Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) }
.labelStyle(.iconOnly).buttonStyle(.borderless)
@@ -517,7 +522,10 @@
content.font(.callout).foregroundStyle(.white)
.environment(\.colorScheme, .dark)
.padding(.horizontal, 14).padding(.vertical, 8)
- .callOverlaySurface(Capsule())
+ // Without Liquid Glass: its lit rim is the faint light edge Paul
+ // saw round the sharing notice (2026-09-28), and no overlay has
+ // an edge since 0.3.7.
+ .callOverlaySurface(Capsule(), rim: false)
.shadow(color: .black.opacity(0.25), radius: 8, y: 2)
}
}
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index aa6739c..609fe81 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -860,22 +860,32 @@ struct PeerAudioReading: Equatable, Sendable {
}
}
- /// A fresh encoder: the screen sent smaller from now on. A
- /// change of size is what makes WebRTC set its encoder up again from
- /// nothing — for H.264 on a Mac, a new VideoToolbox session, which is
- /// the part that can go bad under a sleep or a GPU switch and hold the
- /// frames it was given. And it stays smaller, for the rest of this
- /// share on this connection: the other way an encoder stops is a frame
- /// it cannot take at all (1080p's block count, see `fitted`), and a size
- /// put back would stop it again. A third off each side each time, down
- /// to a quarter of the size, is a price worth a picture that moves.
+ /// A fresh encoder: the screen sent smaller for a while. A change of
+ /// size is what makes WebRTC set its encoder up again from nothing — for
+ /// H.264 on a Mac, a new VideoToolbox session, which is the part that
+ /// can go bad under a sleep or a GPU switch and hold the frames it was
+ /// given. The other way an encoder stops is a frame it cannot take at
+ /// all (the level's block count, see ScreenSendPolicy), which a smaller
+ /// size is also the way out of. A third off each side each time, down to
+ /// a quarter; full size again after thirty seconds, and if that stops it
+ /// again the watchdog comes back round, after its rest.
func recreateScreenEncoder() {
guard screenTx != nil else { return }
nudge = min(nudge * 1.5, 4)
+ nudges += 1
+ let mine = nudges
ScreenLog.info("screen to \(ScreenWatchdog.short(id)) now sent at 1/\(String(format: "%.2f", nudge)) of its size")
applySendPolicy()
setScreenActive(true)
+ Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: 30_000_000_000)
+ guard let self, self.nudges == mine, self.nudge > 1 else { return }
+ self.nudge = 1
+ ScreenLog.info("screen to \(ScreenWatchdog.short(self.id)) back to full size")
+ self.applySendPolicy()
+ }
}
+ private var nudges = 0
private var nudge = 1.0
/// What stopping and sharing again did, on this connection alone: its
diff --git a/native/apple/Sources/CallsKit/ScreenWatchdog.swift b/native/apple/Sources/CallsKit/ScreenWatchdog.swift
index eabdc35..8286145 100644
--- a/native/apple/Sources/CallsKit/ScreenWatchdog.swift
+++ b/native/apple/Sources/CallsKit/ScreenWatchdog.swift
@@ -40,6 +40,11 @@ struct ScreenSendCounts: Equatable, Sendable {
var encoded: UInt64 = 0
var sent: UInt64 = 0
var keyframes: UInt64 = 0
+ /// WebRTC says the link is what is holding the screen back. Frames stop
+ /// being encoded for a while when the estimate collapses, and that is
+ /// the link's to put right, not the encoder's: the watchdog leaves it
+ /// alone (a receiver whose picture stops still reports it).
+ var bandwidthLimited = false
}
/// What the sharer's watchdog asks for. Each is cheaper than the next.
@@ -68,21 +73,33 @@ enum ScreenRecovery: Equatable, Sendable {
/// exactly that. Asked for: a fresh capture, with a growing wait
/// between tries.
/// - **The encoder stops.** Frames go into the source and none come out
-/// encoded (or none are sent) for three seconds, on one connection.
+/// encoded (or none are sent) for five seconds, on one connection, while
+/// the link is not what is holding it back.
/// Asked for, three seconds apart while it lasts: a keyframe, a fresh
/// encoder, then the transceiver itself; then a pause of thirty seconds
/// before starting again.
/// - **A receiver says so.** Someone's `screenStalled` names this peer
-/// (spec §15.21). The same three steps for that connection, four
-/// seconds apart, because the report is a few seconds old when it lands.
+/// (spec §15.21). A keyframe for that connection, four seconds apart
+/// (the report is a few seconds old when it lands), three times and then
+/// a rest — and the fuller steps only if the screen has stopped leaving
+/// here too.
struct ScreenWatchdog: Equatable {
static let captureStallAfter = 3.0
static let captureBackoffFirst = 5.0
static let captureBackoffMost = 60.0
- static let encoderStallAfter = 3.0
+ /// Five seconds, not three: a link that has just been capped can hold a
+ /// large keyframe back for that long in Text and detail, and a forced
+ /// keyframe there only makes it longer (the motion gate, 2026-09-28).
+ static let encoderStallAfter = 5.0
static let encoderStepEvery = 3.0
static let reportStepEvery = 4.0
static let holdOff = 30.0
+ /// No encoder verdict in a connection's first ten seconds: a screen
+ /// kept at full size (Text and detail) on a link still ramping up from
+ /// WebRTC's 300 kbit/s start encodes almost nothing for several seconds,
+ /// and that is not a stall (the motion gate, 2026-09-28). A receiver's
+ /// report is still answered then.
+ static let warmUp = 10.0
struct Peer: Equatable {
var last = ScreenSendCounts()
@@ -94,6 +111,7 @@ struct ScreenWatchdog: Equatable {
var stepAt = 0.0
var reported = false
var restingUntil = 0.0
+ var since = 0.0
}
private(set) var peers: [String: Peer] = [:]
@@ -158,7 +176,7 @@ struct ScreenWatchdog: Equatable {
// watchdog has just re-added, above all — keeps its record, rest and
// all, so the steps do not start again from nothing on the new one.
for (id, counts) in sending {
- var peer = peers[id] ?? Peer(last: counts, movedAt: now, deliveredAt: delivered)
+ var peer = peers[id] ?? Peer(last: counts, movedAt: now, deliveredAt: delivered, since: now)
if counts.encoded < peer.last.encoded || counts.sent < peer.last.sent {
// A new transceiver's counters, starting again from nothing.
peer.last = counts
@@ -182,12 +200,21 @@ struct ScreenWatchdog: Equatable {
peer.movedAt = now
peer.deliveredAt = delivered
}
- let starved = !moved && now - peer.movedAt >= Self.encoderStallAfter && delivered > peer.deliveredAt
+ let starved =
+ !moved && !counts.bandwidthLimited && now - peer.since >= Self.warmUp
+ && now - peer.movedAt >= Self.encoderStallAfter
+ && delivered > peer.deliveredAt
if (starved || reported) && now >= peer.restingUntil {
let every = reported && !starved ? Self.reportStepEvery : Self.encoderStepEvery
if peer.step == 0 || now - peer.stepAt >= every {
let why = starved ? "not encoding or sending" : "reported stalled by the receiver"
- switch peer.step {
+ // A receiver's report while this side's screen is leaving
+ // normally is not the encoder's fault — a slow link, a lost
+ // packet — and a keyframe is all it can use: a smaller or a new
+ // encoder would only take the picture down for nothing (Text and
+ // detail after a thin link, the motion gate, 2026-09-28). Only a
+ // screen that has stopped here as well goes further.
+ switch starved ? peer.step : 0 {
case 0:
out.append(.keyframe(id))
keyframes += 1
diff --git a/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift
index 2f9a9cb..3e125e3 100644
--- a/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift
+++ b/native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift
@@ -1,6 +1,7 @@
+import SwiftUI
import XCTest
-@testable import CallsKit
+@_spi(Fixtures) @testable import CallsKit
/// The screen watchdogs (ScreenWatchdog.swift): the sharer's, which puts
/// its own screen right, and the receiver's, which says when someone
@@ -65,7 +66,7 @@ final class ScreenWatchdogTests: XCTestCase {
func testAnEncoderThatStopsGetsAKeyframeThenAFreshEncoderThenANewTransceiver() {
var dog = ScreenWatchdog()
var log: [(Int, ScreenRecovery)] = []
- for t in 0..<45 {
+ for t in 0..<47 {
let delivered = UInt64(t * 30)
let encoded: UInt64 = t < 5 ? delivered : 120
for a in dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: []) {
@@ -73,9 +74,10 @@ final class ScreenWatchdogTests: XCTestCase {
}
}
XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky"), .keyframe("vicky")])
- // Three seconds after it stopped (t=4), three seconds apart, then a
+ // Five seconds after it stopped (t=4), three seconds apart, then a
// rest of thirty seconds before starting over.
- XCTAssertEqual(log.map(\.0), [7, 10, 13, 43])
+ // (not before the connection's first ten seconds are up)
+ XCTAssertEqual(log.map(\.0), [10, 13, 16, 46])
XCTAssertEqual(dog.keyframes, 2)
XCTAssertEqual(dog.encoderRecoveries, 1)
XCTAssertEqual(dog.reAdds, 1)
@@ -86,13 +88,34 @@ final class ScreenWatchdogTests: XCTestCase {
var actions: [ScreenRecovery] = []
for t in 0..<30 {
let delivered = UInt64(t * 30)
- let encoded: UInt64 = (5..<9).contains(t) ? 120 : delivered
+ let encoded: UInt64 = (5..<11).contains(t) ? 120 : delivered
actions += dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded), stalledBy: [])
}
XCTAssertEqual(actions, [.keyframe("vicky")])
XCTAssertFalse(dog.recovering)
}
+ func testNothingEncodedInTheFirstTenSecondsIsAWarmUp() {
+ var dog = ScreenWatchdog()
+ for t in 0..<10 {
+ let delivered = UInt64(t * 30)
+ XCTAssertEqual(dog.observe(at: Double(t), capture: counts(delivered, delivered), sending: sending(1), stalledBy: []), [])
+ }
+ XCTAssertEqual(dog.observe(at: 10, capture: counts(300, 300), sending: sending(1), stalledBy: []), [.keyframe("vicky")])
+ }
+
+ func testALinkThatHoldsTheScreenBackIsNotAnEncoderStall() {
+ // The estimate collapses and WebRTC stops encoding for a while: the
+ // link's business, and a smaller encoder would only stay smaller.
+ var dog = ScreenWatchdog()
+ for t in 0..<20 {
+ let delivered = UInt64(t * 30)
+ let encoded: UInt64 = t < 5 ? delivered : 120
+ let counts = ["vicky": ScreenSendCounts(encoded: encoded, sent: encoded, keyframes: 1, bandwidthLimited: t >= 5)]
+ XCTAssertEqual(dog.observe(at: Double(t), capture: self.counts(delivered, delivered), sending: counts, stalledBy: []), [])
+ }
+ }
+
func testNothingGoingInIsNotAnEncoderStall() {
// No frames delivered (a broadcast of a still screen): nothing to encode.
var dog = ScreenWatchdog()
@@ -103,7 +126,7 @@ final class ScreenWatchdogTests: XCTestCase {
}
}
- func testAReceiversReportRunsTheSameStepsFourSecondsApart() {
+ func testAReceiversReportWhileTheScreenLeavesNormallyGetsKeyframesOnly() {
var dog = ScreenWatchdog()
var log: [(Int, ScreenRecovery)] = []
for t in 0..<20 {
@@ -113,12 +136,25 @@ final class ScreenWatchdogTests: XCTestCase {
log.append((t, a))
}
}
- XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .recreateEncoder("vicky"), .reAdd("vicky")])
+ XCTAssertEqual(log.map(\.1), [.keyframe("vicky"), .keyframe("vicky"), .keyframe("vicky")])
XCTAssertEqual(log.map(\.0), [3, 7, 11])
XCTAssertEqual(dog.stallReports, 1, "one report, however long it lasts")
XCTAssertTrue(dog.recovering)
}
+ func testAReceiversReportWhileTheScreenHasStoppedHereTooGoesFurther() {
+ var dog = ScreenWatchdog()
+ var actions: [ScreenRecovery] = []
+ for t in 0..<30 {
+ let delivered = UInt64(t * 30)
+ let encoded: UInt64 = t < 5 ? delivered : 120
+ actions += dog.observe(
+ at: Double(t), capture: counts(delivered, delivered), sending: sending(encoded),
+ stalledBy: t >= 6 ? ["vicky"] : [])
+ }
+ XCTAssertTrue(actions.contains(.recreateEncoder("vicky")))
+ }
+
func testAReportThatClearsEndsTheRecovery() {
var dog = ScreenWatchdog()
var actions: [ScreenRecovery] = []
@@ -234,3 +270,37 @@ final class ScreenWatchdogTests: XCTestCase {
XCTAssertFalse(ICERenewal.restart(gathered: ice(10_000), next: ice(0, servers: 0), now: 20_000))
}
}
+
+/// The sharing notice's own Stop sharing button (Paul, 2026-09-28: "I
+/// would expect this notification to have a 'Stop sharing' button
+/// inline"), and the recovery notice stacked under it.
+@MainActor final class SharingNoticeTests: XCTestCase {
+ func testTheSharingNoticeCarriesStopSharingAndTheRecoveryNoticeSitsUnderIt() {
+ let notices = CallNotices.notices(
+ phase: "admitted", status: "In the meeting", sharingScreen: true, screenNote: "", floor: false,
+ screenRecovery: ScreenWords.recovering(names: ["Vicky"]))
+ XCTAssertEqual(notices.map(\.kind), [.sharing, .screenRecovering])
+ XCTAssertEqual(notices[0].text, "You are sharing your screen")
+ XCTAssertEqual(notices[0].action, .stopSharing)
+ XCTAssertNil(notices[1].action, "the recovery notice has no button of its own")
+ XCTAssertTrue(notices.allSatisfy(\.important), "both stay while the controls fade")
+ XCTAssertFalse(notices.contains(where: \.dismissible))
+ XCTAssertNotEqual(notices[0].id, notices[1].id, "two rows, never one over the other")
+ }
+
+ func testNotSharingHasNoStopButton() {
+ let notices = CallNotices.notices(
+ phase: "admitted", status: "In the meeting", sharingScreen: false, screenNote: "", floor: false,
+ screenRecovery: "ignored while not sharing")
+ XCTAssertTrue(notices.allSatisfy { $0.action == nil })
+ XCTAssertFalse(notices.contains { $0.kind == .screenRecovering })
+ }
+
+ func testTheButtonStopsTheShare() {
+ let call = Call(playAudio: false)
+ call.showFixtureSelf(muted: false, cameraOff: false, sharingScreen: true)
+ XCTAssertTrue(call.sharingScreen)
+ call.perform(.stopSharing)
+ XCTAssertFalse(call.sharingScreen)
+ }
+}
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift
index cfdb26f..4676f9b 100644
--- a/native/ios/Sources/PhoneFullScreenCall.swift
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -267,7 +267,7 @@ extension PhoneCall {
private var notices: [CallNotice] {
CallNotices.notices(
phase: call.state?.phase, status: call.status, sharingScreen: call.sharingScreen,
- screenNote: call.screenNote, floor: call.floor)
+ screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery)
}
private func topOverlay(safe: EdgeInsets) -> some View {
@@ -312,6 +312,8 @@ extension PhoneCall {
case .sharing:
Label(notice.text, systemImage: "rectangle.on.rectangle")
.foregroundStyle(CallOverlay.alertText).bold()
+ case .screenRecovering:
+ Label(notice.text, systemImage: "arrow.clockwise")
case .reconnecting, .waitingToJoin:
ProgressView().tint(.white)
Text(notice.text)
@@ -323,6 +325,10 @@ extension PhoneCall {
case .problem, .floor:
Label(notice.text, systemImage: "exclamationmark.triangle.fill")
}
+ if let action = notice.action {
+ // The same stop as the ⋯ sheet's, which ends the broadcast.
+ NoticeActionButton(action) { call.perform(action) }
+ }
if notice.dismissible {
Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) }
.labelStyle(.iconOnly)
@@ -412,7 +418,8 @@ struct PhoneNoticeSurface: ViewModifier {
content.font(.callout).foregroundStyle(.white)
.environment(\.colorScheme, .dark)
.padding(.horizontal, 14).padding(.vertical, 8)
- .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ // No Liquid Glass rim: see NoticeSurface on the Mac.
+ .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous), rim: false)
.shadow(color: .black.opacity(0.25), radius: 8, y: 2)
}
}
diff --git a/native/ios/project.yml b/native/ios/project.yml
index 3382137..c04231a 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.2'
+ MARKETING_VERSION: '0.4.3'
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/call.mjs b/test/browser/call.mjs
index e1d80b5..1a10f88 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -2067,7 +2067,26 @@ const run = {
check(armed && armed.readyState === 'live',
'the received screen track is live, and watched for its ending (' + JSON.stringify(armed) + ')');
- await sharer.page.click('#share');
+ // Paul, 2026-09-28: the sharing notice carries its own Stop
+ // sharing button, named for a screen reader, reachable from the
+ // keyboard — and this stop goes through it, so the button is
+ // proved to do exactly what the bar's toggle does.
+ const notice = await sharer.page.evaluate(() => {
+ const n = document.getElementById('share-notice');
+ const b = document.getElementById('share-stop');
+ return n && b ? {
+ shown: !n.hidden && n.getClientRects().length > 0,
+ text: (n.querySelector('.calls-share-notice-row span') || {}).textContent || '',
+ label: b.getAttribute('aria-label'),
+ words: (b.textContent || '').trim(),
+ focusable: b.tabIndex >= 0 && !b.disabled,
+ } : null;
+ });
+ check(notice && notice.shown && notice.text === 'You are sharing your screen' &&
+ notice.label === 'Stop sharing your screen' && notice.words === 'Stop sharing' && notice.focusable,
+ 'the sharing notice shows, with its own Stop sharing button (' + JSON.stringify(notice) + ')');
+ await sharer.page.focus('#share-stop');
+ await sharer.page.keyboard.press('Enter');
const removed = await until(async () => {
const s = await state(viewer.page);
@@ -2098,6 +2117,8 @@ const run = {
check(off, 'the Share button is back off, in both cues (' +
JSON.stringify(await shareControl(sharer.page)) + ')');
check((await state(sharer.page)).sharing === false, 'the sharing tab says it has stopped');
+ check(await sharer.page.evaluate(() => document.getElementById('share-notice').hidden),
+ 'the sharing notice goes with the share');
check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');