Merge branch 'fix/screen-share-freeze' into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+324
−50
docs/superpowers/specs/2026-09-07-meet-design.md+8 −6internal/calls/static/calls.css+37 −0internal/calls/static/room.js+14 −3internal/calls/templates/room.html+9 −0native/README.md+8 −5native/apple/Sources/CallsKit/Call.swift+5 −2native/apple/Sources/CallsKit/CallActionStyle.swift+12 −4native/apple/Sources/CallsKit/CallChrome.swift+60 −2native/apple/Sources/CallsKit/MacFullWindowCall.swift+9 −1native/apple/Sources/CallsKit/Media.swift+19 −9native/apple/Sources/CallsKit/ScreenWatchdog.swift+34 −7native/apple/Tests/CallsKitTests/ScreenWatchdogTests.swift+77 −7native/ios/Sources/PhoneFullScreenCall.swift+9 −2native/ios/project.yml+1 −1test/browser/call.mjs+22 −1
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 2.344s 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 9.581s ? amadan.net/oficina/calls/protocol [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 8.999s 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 32.021s
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 d8c9019..31841a9 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -707,3 +707,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 fd8ea9d..63beac8 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(); |
| } |
| } |
| @@ -5176,8 +5186,7 @@ |
| say('You are the only one here.'); |
| return; |
| } |
| - // §15.21: quiet, and only while it lasts. |
| - say(share ? shareRecovery : ''); |
| + say(''); |
| } |
| function isMember() { |
| @@ -5659,6 +5668,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 ac68fb3..9bb8fc7 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -501,7 +501,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 |
| @@ -531,11 +533,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 eb3b5ff..4ae5777 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -2056,7 +2056,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); |
| @@ -2087,6 +2106,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') + ')'); |