| 1 | import Foundation |
| 2 | import os |
| 3 | |
| 4 | // The screen watchdogs: the sharer's, which notices its own screen |
| 5 | // stopping on the way out and puts it right, and the receiver's, which |
| 6 | // notices someone else's screen stopping on the way in and says so. |
| 7 | // |
| 8 | // Written for Paul's report of 2026-09-28 (Mac 0.4.1): "When I was |
| 9 | // screen-sharing, after a while, my screen froze on her end and she was |
| 10 | // no longer able to see my screen. I had to stop and re-share again." A |
| 11 | // stop and a share again is a fresh capture, a fresh transceiver, a fresh |
| 12 | // encoder and a keyframe; the sharer's watchdog does each of those in |
| 13 | // turn, cheapest first, without anyone noticing or pressing anything. |
| 14 | |
| 15 | /// `log show --last 1h --predicate 'subsystem == "net.amadan.oficina.calls" && category == "screen"' --info`. |
| 16 | /// Every line is also kept in memory (the last 200) for "Copy call |
| 17 | /// diagnostics". |
| 18 | enum ScreenLog { |
| 19 | static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "screen") |
| 20 | |
| 21 | static func info(_ message: String) { |
| 22 | logger.info("\(message, privacy: .public)") |
| 23 | AudioDiagnosticsStore.screen.log("info", message) |
| 24 | } |
| 25 | |
| 26 | static func error(_ message: String) { |
| 27 | logger.error("\(message, privacy: .public)") |
| 28 | AudioDiagnosticsStore.screen.log("error", message) |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | extension AudioDiagnosticsStore { |
| 33 | /// The screen log's lines, kept apart from the audio log's so neither |
| 34 | /// pushes the other out. |
| 35 | public static let screen = AudioDiagnosticsStore() |
| 36 | } |
| 37 | |
| 38 | /// One connection's screen on the way out, as the watchdog reads it. |
| 39 | struct ScreenSendCounts: Equatable, Sendable { |
| 40 | var encoded: UInt64 = 0 |
| 41 | var sent: UInt64 = 0 |
| 42 | var keyframes: UInt64 = 0 |
| 43 | /// WebRTC says the link is what is holding the screen back. Frames stop |
| 44 | /// being encoded for a while when the estimate collapses, and that is |
| 45 | /// the link's to put right, not the encoder's: the watchdog leaves it |
| 46 | /// alone (a receiver whose picture stops still reports it). |
| 47 | var bandwidthLimited = false |
| 48 | } |
| 49 | |
| 50 | /// What the sharer's watchdog asks for. Each is cheaper than the next. |
| 51 | enum ScreenRecovery: Equatable, Sendable { |
| 52 | /// A fresh capture of the same content into the same source. |
| 53 | case restartCapture |
| 54 | /// A keyframe on this connection's screen: the encoding switched off |
| 55 | /// and on again, which WebRTC answers by starting the stream on one. |
| 56 | case keyframe(String) |
| 57 | /// A fresh encoder on this connection: the screen's size nudged, which |
| 58 | /// makes WebRTC set the hardware encoder up again from nothing. |
| 59 | case recreateEncoder(String) |
| 60 | /// What stopping and sharing again did: this connection's screen |
| 61 | /// transceiver stopped and a new one added, renegotiated. |
| 62 | case reAdd(String) |
| 63 | /// A receiver's report that keyframes did not answer: this connection's |
| 64 | /// screen sending path built again — a new transceiver, so a new encoder |
| 65 | /// and, at the far end, a new receiver and decoder — at the same size |
| 66 | /// and mode. What stopping and sharing again does, for that one person. |
| 67 | case rebuild(String) |
| 68 | /// Still reported after that: every connection's screen stopped and |
| 69 | /// added again from the same capture (no picker), which is exactly what |
| 70 | /// Stop and Share did by hand. |
| 71 | case reshare |
| 72 | } |
| 73 | |
| 74 | /// Where the answer to one receiver's report has got to (the sharer's |
| 75 | /// notice says it in words: see ScreenWords.notice). |
| 76 | enum ScreenEscalation: Equatable, Sendable { |
| 77 | case none |
| 78 | /// Keyframes asked for since this time. |
| 79 | case keyframes(since: Double) |
| 80 | /// That peer's screen rebuilt at this time. |
| 81 | case rebuilt(at: Double) |
| 82 | /// Everyone's screen shared again at this time. |
| 83 | case reshared(at: Double) |
| 84 | /// Nothing more to try: the person is told to stop and share again. |
| 85 | case failed |
| 86 | } |
| 87 | |
| 88 | /// The sharer's watchdog: pure, fed once a second while sharing. |
| 89 | /// |
| 90 | /// Three things can freeze a screen on its way out, and each has its own |
| 91 | /// sign: |
| 92 | /// |
| 93 | /// - **The capture stops.** ScreenCaptureKit reports even a still screen |
| 94 | /// (as "idle"), so a capture that reports nothing for three seconds has |
| 95 | /// stopped — a capture with every surface held by the encoder does |
| 96 | /// exactly that. Asked for: a fresh capture, with a growing wait |
| 97 | /// between tries. |
| 98 | /// - **The encoder stops.** Frames go into the source and none come out |
| 99 | /// encoded (or none are sent) for five seconds, on one connection, while |
| 100 | /// the link is not what is holding it back. |
| 101 | /// Asked for, three seconds apart while it lasts: a keyframe, a fresh |
| 102 | /// encoder, then the transceiver itself; then a pause of thirty seconds |
| 103 | /// before starting again. |
| 104 | /// - **A receiver says so.** Someone's `screenStalled` names this peer |
| 105 | /// (spec §15.21). A keyframe for that connection, four seconds apart |
| 106 | /// (the report is a few seconds old when it lands), three times and then |
| 107 | /// a rest — and the fuller steps only if the screen has stopped leaving |
| 108 | /// here too. |
| 109 | /// - **A receiver keeps saying so** (spec §15.22, Paul 2026-09-30: "still |
| 110 | /// seeing issues with screen sharing after a while", the notice sat at |
| 111 | /// "recovering"). Keyframes cannot mend a decoder that has wedged at the |
| 112 | /// far end, or a receiver that has stopped taking this stream at all; |
| 113 | /// a new transceiver can. Still reported six seconds after the first |
| 114 | /// keyframe: that connection's screen is rebuilt, at the same size and |
| 115 | /// mode (a report alone never shrinks a Text and detail share). Still |
| 116 | /// reported ten seconds after that: everyone's screen is shared again |
| 117 | /// from the same capture. Ten seconds more and the sharer is told it |
| 118 | /// could not be restored. A rebuild at most once a minute per person, |
| 119 | /// a share again at most once every five minutes. |
| 120 | struct ScreenWatchdog: Equatable { |
| 121 | static let captureStallAfter = 3.0 |
| 122 | static let captureBackoffFirst = 5.0 |
| 123 | static let captureBackoffMost = 60.0 |
| 124 | /// Five seconds, not three: a link that has just been capped can hold a |
| 125 | /// large keyframe back for that long in Text and detail, and a forced |
| 126 | /// keyframe there only makes it longer (the motion gate, 2026-09-28). |
| 127 | static let encoderStallAfter = 5.0 |
| 128 | static let encoderStepEvery = 3.0 |
| 129 | static let reportStepEvery = 4.0 |
| 130 | static let holdOff = 30.0 |
| 131 | /// No encoder verdict in a connection's first ten seconds: a screen |
| 132 | /// kept at full size (Text and detail) on a link still ramping up from |
| 133 | /// WebRTC's 300 kbit/s start encodes almost nothing for several seconds, |
| 134 | /// and that is not a stall (the motion gate, 2026-09-28). A receiver's |
| 135 | /// report is still answered then. |
| 136 | static let warmUp = 10.0 |
| 137 | /// §15.22's escalation: how long a report may outlast the first |
| 138 | /// keyframe, then the rebuild, then the share again; and how often each |
| 139 | /// of the last two may happen. |
| 140 | static let rebuildAfter = 6.0 |
| 141 | static let reshareAfter = 10.0 |
| 142 | static let giveUpAfter = 10.0 |
| 143 | static let rebuildEvery = 60.0 |
| 144 | static let reshareEvery = 300.0 |
| 145 | |
| 146 | struct Peer: Equatable { |
| 147 | var last = ScreenSendCounts() |
| 148 | var movedAt: Double |
| 149 | /// Delivered frames when `movedAt` was last set: a connection is only |
| 150 | /// stalled if frames went in meanwhile. |
| 151 | var deliveredAt: UInt64 |
| 152 | var step = 0 |
| 153 | var stepAt = 0.0 |
| 154 | var reported = false |
| 155 | var restingUntil = 0.0 |
| 156 | var since = 0.0 |
| 157 | var escalation = ScreenEscalation.none |
| 158 | var lastRebuild: Double? |
| 159 | /// Frames sent on this connection since the escalation last moved on: |
| 160 | /// a receiver can only be failing to decode frames that were sent. A |
| 161 | /// screen the link is holding back, or one with nothing new to send, |
| 162 | /// is not taken further. |
| 163 | var sentSinceStage: UInt64 = 0 |
| 164 | } |
| 165 | /// Frames that must leave for a receiver before its report is taken |
| 166 | /// further than keyframes. |
| 167 | static let escalateAfterSent: UInt64 = 3 |
| 168 | |
| 169 | private(set) var peers: [String: Peer] = [:] |
| 170 | private var heartbeats: UInt64? |
| 171 | private var heartbeatAt = 0.0 |
| 172 | private var delivered: UInt64 = 0 |
| 173 | private var captureRetryAt = 0.0 |
| 174 | private var captureBackoff = ScreenWatchdog.captureBackoffFirst |
| 175 | private(set) var captureStalled = false |
| 176 | |
| 177 | // For the readout and the gate. |
| 178 | private(set) var captureRestarts = 0 |
| 179 | private(set) var keyframes = 0 |
| 180 | private(set) var encoderRecoveries = 0 |
| 181 | private(set) var reAdds = 0 |
| 182 | private(set) var stallReports = 0 |
| 183 | private(set) var rebuilds = 0 |
| 184 | private(set) var reshares = 0 |
| 185 | private(set) var failures = 0 |
| 186 | private var lastReshare: Double? |
| 187 | /// One line on what the watchdog is doing, for the device panel. |
| 188 | private(set) var status = "watching" |
| 189 | |
| 190 | init() {} |
| 191 | |
| 192 | /// Forgets everything: a share starting, or a restart that should be |
| 193 | /// given a clean look. |
| 194 | mutating func reset() { self = ScreenWatchdog() } |
| 195 | |
| 196 | /// One look. `capture` is the capture's running totals; `sending` each |
| 197 | /// connection's screen on the way out, by peer id, for the connections |
| 198 | /// that have one negotiated; `stalledBy` the peers whose latest report |
| 199 | /// says our screen has stalled for them. Returns what to do now. |
| 200 | mutating func observe( |
| 201 | at now: Double, capture: ScreenCaptureCounts, sending: [String: ScreenSendCounts], |
| 202 | stalledBy: Set<String> |
| 203 | ) -> [ScreenRecovery] { |
| 204 | var out: [ScreenRecovery] = [] |
| 205 | // The capture. |
| 206 | let fresh = heartbeats == nil |
| 207 | if let beats = capture.heartbeats { |
| 208 | if fresh || beats != heartbeats { |
| 209 | heartbeats = beats |
| 210 | heartbeatAt = now |
| 211 | if captureStalled { |
| 212 | captureStalled = false |
| 213 | captureBackoff = Self.captureBackoffFirst |
| 214 | ScreenLog.info("capture reporting again") |
| 215 | } |
| 216 | } else if now - heartbeatAt >= Self.captureStallAfter { |
| 217 | if !captureStalled { |
| 218 | captureStalled = true |
| 219 | ScreenLog.info("capture stalled: nothing reported for \(Int(now - heartbeatAt)) s") |
| 220 | } |
| 221 | if now >= captureRetryAt { |
| 222 | out.append(.restartCapture) |
| 223 | captureRestarts += 1 |
| 224 | captureRetryAt = now + captureBackoff |
| 225 | captureBackoff = min(captureBackoff * 2, Self.captureBackoffMost) |
| 226 | } |
| 227 | } |
| 228 | } |
| 229 | delivered = capture.delivered |
| 230 | // Each connection. |
| 231 | // A connection that has no screen negotiated for the moment — one the |
| 232 | // watchdog has just re-added, above all — keeps its record, rest and |
| 233 | // all, so the steps do not start again from nothing on the new one. |
| 234 | for (id, counts) in sending { |
| 235 | var peer = peers[id] ?? Peer(last: counts, movedAt: now, deliveredAt: delivered, since: now) |
| 236 | peer.sentSinceStage += counts.sent >= peer.last.sent ? counts.sent - peer.last.sent : counts.sent |
| 237 | if counts.encoded < peer.last.encoded || counts.sent < peer.last.sent { |
| 238 | // A new transceiver's counters, starting again from nothing. |
| 239 | peer.last = counts |
| 240 | peer.movedAt = now |
| 241 | peer.deliveredAt = delivered |
| 242 | } |
| 243 | let moved = counts.encoded > peer.last.encoded && counts.sent > peer.last.sent |
| 244 | let wasReported = peer.reported |
| 245 | let reported = stalledBy.contains(id) |
| 246 | if reported && !wasReported { stallReports += 1 } |
| 247 | peer.reported = reported |
| 248 | peer.last = counts |
| 249 | if moved && !reported { |
| 250 | if peer.step > 0 { |
| 251 | ScreenLog.info("screen to \(Self.short(id)) moving again after step \(peer.step)") |
| 252 | peer.step = 0 |
| 253 | } |
| 254 | peer.movedAt = now |
| 255 | peer.deliveredAt = delivered |
| 256 | } else if moved { |
| 257 | peer.movedAt = now |
| 258 | peer.deliveredAt = delivered |
| 259 | } |
| 260 | let starved = |
| 261 | !moved && !counts.bandwidthLimited && now - peer.since >= Self.warmUp |
| 262 | && now - peer.movedAt >= Self.encoderStallAfter |
| 263 | && delivered > peer.deliveredAt |
| 264 | if (starved || reported) && now >= peer.restingUntil { |
| 265 | let every = reported && !starved ? Self.reportStepEvery : Self.encoderStepEvery |
| 266 | if peer.step == 0 || now - peer.stepAt >= every { |
| 267 | let why = starved ? "not encoding or sending" : "reported stalled by the receiver" |
| 268 | // A receiver's report while this side's screen is leaving |
| 269 | // normally is not the encoder's fault — a slow link, a lost |
| 270 | // packet — and a keyframe is all it can use: a smaller or a new |
| 271 | // encoder would only take the picture down for nothing (Text and |
| 272 | // detail after a thin link, the motion gate, 2026-09-28). Only a |
| 273 | // screen that has stopped here as well goes further. |
| 274 | switch starved ? peer.step : 0 { |
| 275 | case 0: |
| 276 | out.append(.keyframe(id)) |
| 277 | keyframes += 1 |
| 278 | case 1: |
| 279 | out.append(.recreateEncoder(id)) |
| 280 | encoderRecoveries += 1 |
| 281 | default: |
| 282 | out.append(.reAdd(id)) |
| 283 | reAdds += 1 |
| 284 | } |
| 285 | peer.step += 1 |
| 286 | peer.stepAt = now |
| 287 | ScreenLog.info("screen to \(Self.short(id)) \(why): step \(peer.step)") |
| 288 | if peer.step >= 3 { |
| 289 | peer.step = 0 |
| 290 | peer.restingUntil = now + Self.holdOff |
| 291 | } |
| 292 | } |
| 293 | } else if !starved && !reported && peer.step > 0 && now - peer.stepAt >= Self.encoderStepEvery { |
| 294 | peer.step = 0 |
| 295 | } |
| 296 | peers[id] = peer |
| 297 | } |
| 298 | out += escalate(at: now, stalledBy: stalledBy, negotiated: Set(sending.keys)) |
| 299 | status = describe() |
| 300 | return out |
| 301 | } |
| 302 | |
| 303 | /// §15.22: a report that keyframes have not answered, taken further. Run |
| 304 | /// over every peer with a record, whether or not its screen is |
| 305 | /// negotiated this second — a rebuilt connection has none for a moment, |
| 306 | /// and a rebuild that never comes back must still end in words. |
| 307 | private mutating func escalate( |
| 308 | at now: Double, stalledBy: Set<String>, negotiated: Set<String> |
| 309 | ) -> [ScreenRecovery] { |
| 310 | var out: [ScreenRecovery] = [] |
| 311 | var reshare = false |
| 312 | for id in peers.keys.sorted() { |
| 313 | guard var peer = peers[id] else { continue } |
| 314 | let reported = stalledBy.contains(id) |
| 315 | // Frames went to this receiver since the last step, or there is no |
| 316 | // screen negotiated with it at all (a rebuild that never came back). |
| 317 | let owed = peer.sentSinceStage >= Self.escalateAfterSent || !negotiated.contains(id) |
| 318 | let before = peer.escalation |
| 319 | defer { |
| 320 | if peers[id]?.escalation != before { peers[id]?.sentSinceStage = 0 } |
| 321 | } |
| 322 | if !reported { |
| 323 | if peer.escalation != .none { |
| 324 | switch peer.escalation { |
| 325 | case .keyframes: ScreenLog.info("\(Self.short(id))'s view restored after keyframes") |
| 326 | case .rebuilt: ScreenLog.info("\(Self.short(id))'s view restored after the rebuild") |
| 327 | case .reshared: ScreenLog.info("\(Self.short(id))'s view restored after sharing again") |
| 328 | case .failed: ScreenLog.info("\(Self.short(id))'s view came back after all") |
| 329 | case .none: break |
| 330 | } |
| 331 | peer.escalation = .none |
| 332 | } |
| 333 | peers[id] = peer |
| 334 | continue |
| 335 | } |
| 336 | let canRebuild = peer.lastRebuild.map { now - $0 >= Self.rebuildEvery } ?? true |
| 337 | let canReshare = reshare || (lastReshare.map { now - $0 >= Self.reshareEvery } ?? true) |
| 338 | switch peer.escalation { |
| 339 | case .none: |
| 340 | peer.escalation = .keyframes(since: now) |
| 341 | case .keyframes(let since) where now - since >= Self.rebuildAfter && owed: |
| 342 | if canRebuild { |
| 343 | out.append(.rebuild(id)) |
| 344 | rebuilds += 1 |
| 345 | peer.lastRebuild = now |
| 346 | peer.escalation = .rebuilt(at: now) |
| 347 | ScreenLog.info( |
| 348 | "\(Self.short(id)) still reports a stall \(Int(now - since)) s after keyframes: rebuilding its screen") |
| 349 | } else if canReshare { |
| 350 | reshare = true |
| 351 | peer.escalation = .reshared(at: now) |
| 352 | ScreenLog.info("\(Self.short(id)) still reports a stall, rebuilt under a minute ago: sharing again") |
| 353 | } else { |
| 354 | peer.escalation = .failed |
| 355 | failures += 1 |
| 356 | ScreenLog.error("\(Self.short(id)) still reports a stall and every step is resting: giving up") |
| 357 | } |
| 358 | case .rebuilt(let at) where now - at >= Self.reshareAfter && owed: |
| 359 | if canReshare { |
| 360 | reshare = true |
| 361 | peer.escalation = .reshared(at: now) |
| 362 | ScreenLog.info("\(Self.short(id)) still reports a stall \(Int(now - at)) s after the rebuild: sharing again") |
| 363 | } else { |
| 364 | peer.escalation = .failed |
| 365 | failures += 1 |
| 366 | ScreenLog.error("\(Self.short(id)) still reports a stall after the rebuild, shared again under five minutes ago: giving up") |
| 367 | } |
| 368 | case .reshared(let at) where now - at >= Self.giveUpAfter && owed: |
| 369 | peer.escalation = .failed |
| 370 | failures += 1 |
| 371 | ScreenLog.error("\(Self.short(id)) still reports a stall \(Int(now - at)) s after sharing again: giving up") |
| 372 | default: |
| 373 | break |
| 374 | } |
| 375 | peers[id] = peer |
| 376 | } |
| 377 | if reshare { |
| 378 | out.append(.reshare) |
| 379 | reshares += 1 |
| 380 | lastReshare = now |
| 381 | // One share again answers every report in flight. |
| 382 | for (id, peer) in peers where stalledBy.contains(id) { |
| 383 | switch peer.escalation { |
| 384 | case .keyframes, .rebuilt: |
| 385 | peers[id]?.escalation = .reshared(at: now) |
| 386 | peers[id]?.sentSinceStage = 0 |
| 387 | default: break |
| 388 | } |
| 389 | } |
| 390 | } |
| 391 | return out |
| 392 | } |
| 393 | |
| 394 | /// Where the answer to each reporting peer has got to, for the notice. |
| 395 | func escalation(of peer: String) -> ScreenEscalation { peers[peer]?.escalation ?? .none } |
| 396 | |
| 397 | /// Whether anyone's view of the screen is being recovered right now. |
| 398 | var recovering: Bool { captureStalled || peers.values.contains { $0.step > 0 || $0.reported } } |
| 399 | |
| 400 | private func describe() -> String { |
| 401 | if captureStalled { return "capture stalled, restarting (\(captureRestarts))" } |
| 402 | let stages = peers.values.map(\.escalation) |
| 403 | if stages.contains(.failed) { return "a receiver's view could not be restored" } |
| 404 | if stages.contains(where: { if case .reshared = $0 { return true } else { return false } }) { |
| 405 | return "shared again for everyone" |
| 406 | } |
| 407 | if stages.contains(where: { if case .rebuilt = $0 { return true } else { return false } }) { |
| 408 | return "rebuilt for a receiver" |
| 409 | } |
| 410 | if let (_, p) = peers.first(where: { $0.value.step > 0 }) { |
| 411 | return ["", "keyframe asked for", "encoder recreated", "re-added"][min(p.step, 3)] |
| 412 | } |
| 413 | if peers.values.contains(where: \.reported) { return "a receiver reports a stall" } |
| 414 | let total = captureRestarts + keyframes + encoderRecoveries + reAdds + rebuilds + reshares |
| 415 | return total == 0 ? "watching" : "watching, \(total) recover\(total == 1 ? "y" : "ies") so far" |
| 416 | } |
| 417 | |
| 418 | static func short(_ peer: String) -> String { String(peer.prefix(6)) } |
| 419 | } |
| 420 | |
| 421 | /// The receiver's watchdog: whose screens have stopped moving here. |
| 422 | /// |
| 423 | /// A peer's screen is stalled when nothing new has been decoded from it |
| 424 | /// for four seconds while it says it is sharing. Every sender repeats a |
| 425 | /// still screen at least once a second (the iPhone's broadcast too, since |
| 426 | /// 2026-09-30), so four seconds without a frame is not a still screen. The |
| 427 | /// list is reported as `screenStalled` (spec §15.21) and the sharer |
| 428 | /// recovers; it clears as soon as frames move. |
| 429 | /// |
| 430 | /// Any change in the count is movement, a smaller one included: a screen |
| 431 | /// the sharer has rebuilt arrives on a new receiver whose count starts |
| 432 | /// again from nothing (2026-09-30 — before this, a rebuilt screen that was |
| 433 | /// moving again stayed "stalled" here until its new count passed the old |
| 434 | /// one, and the sharer's notice with it). |
| 435 | /// |
| 436 | /// Four seconds into a stall the tile's picture is also dropped and |
| 437 | /// attached again, once (spec §15.22): a renderer that has stopped, on |
| 438 | /// this side, is this side's to mend. A report is held for at most three |
| 439 | /// minutes from the last frame, long enough for the sharer to try |
| 440 | /// everything and say so, and no longer. |
| 441 | struct ScreenReceiveWatch: Equatable { |
| 442 | static let stallAfter = 4.0 |
| 443 | static let reattachAfter = 4.0 |
| 444 | static let giveUpAfter = 180.0 |
| 445 | |
| 446 | struct Seen: Equatable { |
| 447 | var decoded: UInt64 |
| 448 | var movedAt: Double |
| 449 | var reattached = false |
| 450 | } |
| 451 | private(set) var seen: [String: Seen] = [:] |
| 452 | /// The peers whose picture should be attached again now: set by each |
| 453 | /// look, and each stall's only once. |
| 454 | private(set) var reattach: [String] = [] |
| 455 | |
| 456 | init() {} |
| 457 | |
| 458 | /// `decoded` is frames decoded so far from each peer's screen, for the |
| 459 | /// peers that have one arriving. Returns the stalled peers, sorted. |
| 460 | mutating func observe(at now: Double, decoded: [String: UInt64]) -> [String] { |
| 461 | for id in seen.keys where decoded[id] == nil { seen.removeValue(forKey: id) } |
| 462 | var stalled: [String] = [] |
| 463 | reattach = [] |
| 464 | for (id, count) in decoded { |
| 465 | guard var s = seen[id], count == s.decoded else { |
| 466 | seen[id] = Seen(decoded: count, movedAt: now) |
| 467 | continue |
| 468 | } |
| 469 | let quiet = now - s.movedAt |
| 470 | if quiet >= Self.stallAfter && quiet < Self.giveUpAfter { stalled.append(id) } |
| 471 | if quiet >= Self.stallAfter + Self.reattachAfter && !s.reattached && quiet < Self.giveUpAfter { |
| 472 | s.reattached = true |
| 473 | reattach.append(id) |
| 474 | } |
| 475 | seen[id] = s |
| 476 | } |
| 477 | reattach.sort() |
| 478 | return stalled.sorted() |
| 479 | } |
| 480 | |
| 481 | /// How long each stalled peer's screen has gone without a frame. |
| 482 | func quiet(at now: Double) -> [String: Double] { seen.mapValues { now - $0.movedAt } } |
| 483 | } |
| 484 | |
| 485 | /// The words for the sharer's quiet notice. |
| 486 | public enum ScreenWords { |
| 487 | /// "Vicky’s view of your screen stalled — recovering", or nothing when |
| 488 | /// nobody's has. |
| 489 | public static func recovering(names: [String]) -> String { |
| 490 | switch names.count { |
| 491 | case 0: return "" |
| 492 | case 1: return "\(names[0])\u{2019}s view of your screen stalled \u{2014} recovering" |
| 493 | case 2: return "\(names[0])\u{2019}s and \(names[1])\u{2019}s views of your screen stalled \u{2014} recovering" |
| 494 | default: return "\(names.count) people\u{2019}s views of your screen stalled \u{2014} recovering" |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | /// The notice as the escalation moves on (spec §15.22): "recovering", |
| 499 | /// then "Restarting your share for Samuel…", then either nothing or |
| 500 | /// "Couldn’t restore Samuel’s view — try Stop and Share again". Worst |
| 501 | /// news first when several people are at different steps. |
| 502 | public static func notice(recovering: [String], restarting: [String], failed: [String]) -> String { |
| 503 | var parts: [String] = [] |
| 504 | if !failed.isEmpty { |
| 505 | let whose: String |
| 506 | switch failed.count { |
| 507 | case 1: whose = "\(failed[0])\u{2019}s view" |
| 508 | case 2: whose = "\(failed[0])\u{2019}s and \(failed[1])\u{2019}s views" |
| 509 | default: whose = "\(failed.count) people\u{2019}s views" |
| 510 | } |
| 511 | parts.append("Couldn\u{2019}t restore \(whose) \u{2014} try Stop and Share again") |
| 512 | } |
| 513 | if !restarting.isEmpty { |
| 514 | let who: String |
| 515 | switch restarting.count { |
| 516 | case 1: who = restarting[0] |
| 517 | case 2: who = "\(restarting[0]) and \(restarting[1])" |
| 518 | default: who = "\(restarting.count) people" |
| 519 | } |
| 520 | parts.append("Restarting your share for \(who)\u{2026}") |
| 521 | } |
| 522 | let stalled = Self.recovering(names: recovering) |
| 523 | if !stalled.isEmpty { parts.append(stalled) } |
| 524 | return parts.joined(separator: ". ") |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | /// One peer's screen as it arrives here, from the receiver's statistics |
| 529 | /// (spec §15.22): enough to tell the ways a screen stops apart. Packets |
| 530 | /// arriving and frames assembled but none decoded is a decoder that has |
| 531 | /// wedged; nothing arriving at all is the sender, or the path; frames |
| 532 | /// decoded and a picture that does not move is the renderer. |
| 533 | struct ScreenReceiveReport: Equatable, Sendable { |
| 534 | var packetsReceived: UInt64 = 0 |
| 535 | var packetsLost: Int64 = 0 |
| 536 | var bytesReceived: UInt64 = 0 |
| 537 | var framesReceived: UInt64 = 0 |
| 538 | var framesDecoded: UInt64 = 0 |
| 539 | var framesDropped: UInt64 = 0 |
| 540 | var keyFramesDecoded: UInt64 = 0 |
| 541 | var pliCount: UInt64 = 0 |
| 542 | var firCount: UInt64 = 0 |
| 543 | var nackCount: UInt64 = 0 |
| 544 | var freezeCount: UInt64 = 0 |
| 545 | var width = 0 |
| 546 | var height = 0 |
| 547 | var decoder = "" |
| 548 | /// When the last packet arrived, in the statistics' own milliseconds. |
| 549 | var lastPacketAt = 0.0 |
| 550 | var mid = "" |
| 551 | |
| 552 | init() {} |
| 553 | |
| 554 | /// One inbound-rtp report's values added in (a screen has one). |
| 555 | mutating func add(_ v: [String: NSObject]) { |
| 556 | func n(_ key: String) -> UInt64 { (v[key] as? NSNumber)?.uint64Value ?? 0 } |
| 557 | packetsReceived += n("packetsReceived") |
| 558 | packetsLost += (v["packetsLost"] as? NSNumber)?.int64Value ?? 0 |
| 559 | bytesReceived += n("bytesReceived") |
| 560 | framesReceived += n("framesReceived") |
| 561 | framesDecoded += n("framesDecoded") |
| 562 | framesDropped += n("framesDropped") |
| 563 | keyFramesDecoded += n("keyFramesDecoded") |
| 564 | pliCount += n("pliCount") |
| 565 | firCount += n("firCount") |
| 566 | nackCount += n("nackCount") |
| 567 | freezeCount += n("freezeCount") |
| 568 | width = max(width, Int(n("frameWidth"))) |
| 569 | height = max(height, Int(n("frameHeight"))) |
| 570 | if let name = v["decoderImplementation"] as? String, !name.isEmpty { decoder = name } |
| 571 | lastPacketAt = max(lastPacketAt, (v["lastPacketReceivedTimestamp"] as? NSNumber)?.doubleValue ?? 0) |
| 572 | } |
| 573 | |
| 574 | /// What changed between two looks, in words: "packets +120, frames |
| 575 | /// received +30, decoded +0, dropped +0, keyframes +0, PLI +2". |
| 576 | func moved(since before: ScreenReceiveReport?) -> String { |
| 577 | guard let b = before, b.mid == mid else { return summary } |
| 578 | func d(_ a: UInt64, _ z: UInt64) -> String { a >= z ? "+\(a - z)" : "new \(a)" } |
| 579 | return "packets \(d(packetsReceived, b.packetsReceived)), frames received \(d(framesReceived, b.framesReceived)), " |
| 580 | + "decoded \(d(framesDecoded, b.framesDecoded)), dropped \(d(framesDropped, b.framesDropped)), " |
| 581 | + "keyframes \(d(keyFramesDecoded, b.keyFramesDecoded)), PLI \(d(pliCount, b.pliCount)), " |
| 582 | + "NACK \(d(nackCount, b.nackCount))" |
| 583 | } |
| 584 | |
| 585 | /// The running totals, one line. |
| 586 | var summary: String { |
| 587 | "mid \(mid.isEmpty ? "-" : mid), \(width)x\(height), decoder \(decoder.isEmpty ? "unknown" : decoder); " |
| 588 | + "packets \(packetsReceived) (lost \(packetsLost)), frames received \(framesReceived), " |
| 589 | + "decoded \(framesDecoded), dropped \(framesDropped), keyframes \(keyFramesDecoded), " |
| 590 | + "PLI \(pliCount), FIR \(firCount), NACK \(nackCount), freezes \(freezeCount)" |
| 591 | } |
| 592 | |
| 593 | /// The verdict the numbers point to, for a screen that has stopped. |
| 594 | func diagnosis(since before: ScreenReceiveReport?) -> String { |
| 595 | guard let b = before, b.mid == mid else { return "no earlier reading" } |
| 596 | if packetsReceived == b.packetsReceived { return "no packets arriving (the sender or the path)" } |
| 597 | if framesReceived == b.framesReceived { return "packets arriving, no frames assembled (loss, or waiting on a keyframe)" } |
| 598 | if framesDecoded == b.framesDecoded { return "frames arriving, none decoded (the decoder)" } |
| 599 | return "decoding" |
| 600 | } |
| 601 | } |
| 602 | |