mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:45:20 +00:00
182 lines
6.7 KiB
Swift
182 lines
6.7 KiB
Swift
import Foundation
|
|
import Combine
|
|
import BitLogger
|
|
#if canImport(Network)
|
|
import Network
|
|
#endif
|
|
|
|
/// Coarse, conservative network-reachability signal used to gate Tor bootstrap
|
|
/// and Nostr relay connections.
|
|
///
|
|
/// Policy (deliberately conservative):
|
|
/// - Reports `false` only when the OS says there is *no* usable interface at
|
|
/// all (`NWPath.Status.unsatisfied`). A flaky-but-present link stays
|
|
/// `true` because Tor tolerates intermittent connectivity, and tearing down
|
|
/// on the first hiccup would cost more battery/latency than it saves.
|
|
/// - Transitions are debounced (see `ReachabilityDebounce`) so path flapping
|
|
/// does not thrash Tor/relay startup.
|
|
/// - Starts optimistic (`true`) so nothing is ever suppressed before the first
|
|
/// path evaluation arrives.
|
|
///
|
|
/// BLE mesh must never consult this monitor — the mesh works fully offline.
|
|
@MainActor
|
|
protocol NetworkReachabilityMonitoring: AnyObject {
|
|
/// Current debounced coarse reachability.
|
|
var isReachable: Bool { get }
|
|
/// Emits the debounced reachability whenever it changes (main-actor).
|
|
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
|
/// Begin monitoring. Idempotent.
|
|
func start()
|
|
/// Stop monitoring and discard pending debounce work. Idempotent.
|
|
func stop()
|
|
}
|
|
|
|
/// Pure debounce/decision logic for reachability, split out so it can be
|
|
/// unit-tested without the Network framework or real timers.
|
|
///
|
|
/// A candidate state only becomes the committed state once it has been stable
|
|
/// (uninterrupted) for `interval`. Any observation matching the committed state
|
|
/// cancels a pending opposite change, which is what makes flapping a no-op.
|
|
struct ReachabilityDebounce {
|
|
let interval: TimeInterval
|
|
private(set) var committed: Bool
|
|
private var pending: (value: Bool, since: Date)?
|
|
|
|
init(interval: TimeInterval, initial: Bool) {
|
|
self.interval = interval
|
|
self.committed = initial
|
|
}
|
|
|
|
/// Whether a change is currently waiting out the debounce window.
|
|
var hasPendingChange: Bool { pending != nil }
|
|
|
|
/// Time left before the pending change may commit, or `nil` when nothing
|
|
/// is pending. Lets callers schedule a flush at the true deadline instead
|
|
/// of a full interval from "now" (duplicate observations must not push
|
|
/// the deadline out).
|
|
func pendingRemaining(at now: Date) -> TimeInterval? {
|
|
guard let pending else { return nil }
|
|
return max(0, interval - now.timeIntervalSince(pending.since))
|
|
}
|
|
|
|
/// Feed a raw observation. Returns the new committed value if it changed,
|
|
/// otherwise `nil`.
|
|
mutating func observe(reachable: Bool, at now: Date) -> Bool? {
|
|
if reachable == committed {
|
|
// Already in this state — cancel any pending opposite change.
|
|
pending = nil
|
|
return nil
|
|
}
|
|
// Differs from committed: (re)arm the pending change, preserving the
|
|
// timestamp if we're already waiting on this same target value.
|
|
if pending?.value != reachable {
|
|
pending = (reachable, now)
|
|
}
|
|
return commitIfAged(at: now)
|
|
}
|
|
|
|
/// Called from a timer to commit a pending change once it has aged past
|
|
/// `interval`. Returns the new committed value if it changed, else `nil`.
|
|
mutating func flush(at now: Date) -> Bool? {
|
|
commitIfAged(at: now)
|
|
}
|
|
|
|
private mutating func commitIfAged(at now: Date) -> Bool? {
|
|
guard let pending else { return nil }
|
|
guard now.timeIntervalSince(pending.since) >= interval else { return nil }
|
|
committed = pending.value
|
|
self.pending = nil
|
|
return committed
|
|
}
|
|
}
|
|
|
|
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
|
/// background path callback hops here before touching the debounce.
|
|
@MainActor
|
|
final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
|
private let subject: CurrentValueSubject<Bool, Never>
|
|
private var debounce: ReachabilityDebounce
|
|
private var flushWorkItem: DispatchWorkItem?
|
|
private var started = false
|
|
private let now: () -> Date
|
|
|
|
#if canImport(Network)
|
|
private var monitor: NWPathMonitor?
|
|
private let monitorQueue = DispatchQueue(label: "chat.bitchat.reachability")
|
|
#endif
|
|
|
|
init(debounceInterval: TimeInterval = 2.5, now: @escaping () -> Date = Date.init) {
|
|
self.now = now
|
|
self.debounce = ReachabilityDebounce(interval: debounceInterval, initial: true)
|
|
self.subject = CurrentValueSubject(true)
|
|
}
|
|
|
|
var isReachable: Bool { subject.value }
|
|
|
|
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
|
}
|
|
|
|
func start() {
|
|
guard !started else { return }
|
|
started = true
|
|
#if canImport(Network)
|
|
let monitor = NWPathMonitor()
|
|
self.monitor = monitor
|
|
monitor.pathUpdateHandler = { [weak self] path in
|
|
// Conservative: only "no interface at all" counts as unreachable.
|
|
let reachable = path.status != .unsatisfied
|
|
Task { @MainActor in
|
|
self?.ingest(reachable: reachable)
|
|
}
|
|
}
|
|
monitor.start(queue: monitorQueue)
|
|
#else
|
|
// No Network framework: never suppress startup.
|
|
#endif
|
|
}
|
|
|
|
func stop() {
|
|
guard started else { return }
|
|
started = false
|
|
flushWorkItem?.cancel()
|
|
flushWorkItem = nil
|
|
#if canImport(Network)
|
|
monitor?.pathUpdateHandler = nil
|
|
monitor?.cancel()
|
|
monitor = nil
|
|
#endif
|
|
}
|
|
|
|
/// Feed an observation into the debounce and publish committed changes.
|
|
/// Exposed internally so higher layers/tests could drive it if needed.
|
|
func ingest(reachable: Bool) {
|
|
flushWorkItem?.cancel()
|
|
flushWorkItem = nil
|
|
if let committed = debounce.observe(reachable: reachable, at: now()) {
|
|
publish(committed)
|
|
} else if debounce.hasPendingChange {
|
|
scheduleFlush()
|
|
}
|
|
}
|
|
|
|
private func scheduleFlush() {
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
if let committed = self.debounce.flush(at: self.now()) {
|
|
self.publish(committed)
|
|
}
|
|
}
|
|
flushWorkItem = work
|
|
// Fire at the pending change's real deadline (pending.since + interval):
|
|
// duplicate path updates re-enter here and must not restart the window.
|
|
let delay = debounce.pendingRemaining(at: now()) ?? debounce.interval
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
|
}
|
|
|
|
private func publish(_ reachable: Bool) {
|
|
SecureLogger.info("NWPathReachabilityMonitor: network reachable -> \(reachable)", category: .session)
|
|
subject.send(reachable)
|
|
}
|
|
}
|