oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import 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".
18enum 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
32extension 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.
39struct 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.
51enum 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).
76enum 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.
120struct 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.
441struct 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.
486public 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.
533struct 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