| 1 | import Foundation |
| 2 | import os |
| 3 | |
| 4 | /// When "Reconnecting…" is said, and to whom (spec §15.23). |
| 5 | /// |
| 6 | /// Paul, 2026-09-30: "During that call I also saw 'Reconnecting' appear |
| 7 | /// occasionally over the video, but only for a second … I didn't notice any |
| 8 | /// interruption in the call." The core puts the call in its `reconnecting` |
| 9 | /// phase the moment its signalling stream ends, and the stream ends on |
| 10 | /// purpose every few minutes (its renewal) and is resumed half a second |
| 11 | /// later; the media never notices. So the words wait until a problem has |
| 12 | /// lasted: |
| 13 | /// |
| 14 | /// - the signalling stream down for `streamAfter` (4 s) — the whole call; |
| 15 | /// - a connection `disconnected` or `failed` for `connectionAfter` (3 s), |
| 16 | /// or no audio arriving from someone who is not muted for `audioAfter` |
| 17 | /// (2 s) — that person's tile, "Reconnecting to Samuel…", unless it is |
| 18 | /// everyone (two or more), which is the whole call again. |
| 19 | /// |
| 20 | /// Once said, the words stay `holdAfter` (1 s) past the recovery, so they |
| 21 | /// do not flicker. Pure: fed the facts as they are, with the time. |
| 22 | struct ReconnectWatch: Equatable { |
| 23 | static let streamAfter = 4.0 |
| 24 | static let connectionAfter = 3.0 |
| 25 | static let audioAfter = 2.0 |
| 26 | static let holdAfter = 1.0 |
| 27 | |
| 28 | /// One person's connection as it is now. |
| 29 | struct Peer: Equatable { |
| 30 | /// ICE `disconnected` or `failed`. |
| 31 | var connectionBad = false |
| 32 | /// No audio packets arrived at the last look, and they are not muted. |
| 33 | var audioMissing = false |
| 34 | } |
| 35 | |
| 36 | private var streamDownSince: Double? |
| 37 | private var connectionBadSince: [String: Double] = [:] |
| 38 | private var audioMissingSince: [String: Double] = [:] |
| 39 | private var callClearSince: Double? |
| 40 | private var peerClearSince: [String: Double] = [:] |
| 41 | |
| 42 | /// Whether the whole call is said to be reconnecting. |
| 43 | private(set) var call = false |
| 44 | /// The people whose tiles say "Reconnecting to …". |
| 45 | private(set) var peers: Set<String> = [] |
| 46 | |
| 47 | init() {} |
| 48 | |
| 49 | /// One look. `streamDown` is the core's `reconnecting` phase; `people` |
| 50 | /// everyone in the call but you. |
| 51 | mutating func observe(at now: Double, streamDown: Bool, people: [String: Peer]) { |
| 52 | streamDownSince = streamDown ? (streamDownSince ?? now) : nil |
| 53 | for id in Array(connectionBadSince.keys) where people[id] == nil { connectionBadSince.removeValue(forKey: id) } |
| 54 | for id in Array(audioMissingSince.keys) where people[id] == nil { audioMissingSince.removeValue(forKey: id) } |
| 55 | var troubled: Set<String> = [] |
| 56 | for (id, p) in people { |
| 57 | connectionBadSince[id] = p.connectionBad ? (connectionBadSince[id] ?? now) : nil |
| 58 | audioMissingSince[id] = p.audioMissing ? (audioMissingSince[id] ?? now) : nil |
| 59 | let connection = connectionBadSince[id].map { now - $0 >= Self.connectionAfter } ?? false |
| 60 | let audio = audioMissingSince[id].map { now - $0 >= Self.audioAfter } ?? false |
| 61 | if connection || audio { troubled.insert(id) } |
| 62 | } |
| 63 | let stream = streamDownSince.map { now - $0 >= Self.streamAfter } ?? false |
| 64 | let everyone = troubled.count >= 2 && troubled.count == people.count |
| 65 | let wantCall = stream || everyone |
| 66 | let wantPeers = wantCall ? [] : troubled |
| 67 | |
| 68 | if wantCall { |
| 69 | call = true |
| 70 | callClearSince = nil |
| 71 | } else if call { |
| 72 | let since = callClearSince ?? now |
| 73 | callClearSince = since |
| 74 | if now - since >= Self.holdAfter { |
| 75 | call = false |
| 76 | callClearSince = nil |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | var shown = peers.filter { people[$0] != nil } |
| 81 | for id in wantPeers { |
| 82 | shown.insert(id) |
| 83 | peerClearSince.removeValue(forKey: id) |
| 84 | } |
| 85 | for id in shown where !wantPeers.contains(id) { |
| 86 | // Covered by the whole call's words, or better: held, then gone. |
| 87 | if wantCall { |
| 88 | shown.remove(id) |
| 89 | peerClearSince.removeValue(forKey: id) |
| 90 | continue |
| 91 | } |
| 92 | let since = peerClearSince[id] ?? now |
| 93 | peerClearSince[id] = since |
| 94 | if now - since >= Self.holdAfter { |
| 95 | shown.remove(id) |
| 96 | peerClearSince.removeValue(forKey: id) |
| 97 | } |
| 98 | } |
| 99 | for id in Array(peerClearSince.keys) where !shown.contains(id) { peerClearSince.removeValue(forKey: id) } |
| 100 | peers = shown |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// The words (spec §15.23). |
| 105 | public enum ReconnectWords { |
| 106 | public static let call = "Reconnecting…" |
| 107 | public static func peer(_ name: String) -> String { "Reconnecting to \(name)\u{2026}" } |
| 108 | } |
| 109 | |
| 110 | /// `log show --predicate 'subsystem == "net.amadan.oficina.calls" && category == "connection"' --info`, |
| 111 | /// and the last lines in "Copy call diagnostics": when "Reconnecting…" was |
| 112 | /// said and cleared. |
| 113 | enum CallLog { |
| 114 | static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "connection") |
| 115 | |
| 116 | static func info(_ message: String) { |
| 117 | logger.info("\(message, privacy: .public)") |
| 118 | AudioDiagnosticsStore.connection.log("info", message) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | extension AudioDiagnosticsStore { |
| 123 | /// The connection log's lines, kept apart from the others. |
| 124 | public static let connection = AudioDiagnosticsStore() |
| 125 | } |
| 126 | |