Files
rzen 451abb430b Add an iCloud diagnostics screen
A new Settings > Diagnostics screen reports the ubiquity container and
account status, per-document download and eviction state, network
readiness, and a count of documents skipped by the schema-version
forward gate — surfaced to help debug why a file isn't syncing.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-08 08:03:45 -04:00

165 lines
5.9 KiB
Swift

//
// NetworkReadiness.swift
// Reference code — copy into your app, adjust the type/file names to taste.
//
// Adapted from the IndieDiag package (`NetworkDiagnostic.swift`,
// commit bc6f475). Trimmed to the NWPathMonitor-based status/quality
// estimate only — dropped the original's `testCloudConnectivity()` HTTP
// probe to icloud.com as extra surface a documents-only app doesn't need;
// `NWPathMonitor`'s path status is enough to decide "is this a reasonable
// moment to sync." Combine publishers replaced with a plain `async`
// one-shot read plus an `AsyncStream` for the rare screen that wants live
// updates while visible.
//
import Foundation
import Network
/// Network connection type.
public enum ConnectionType: String, Sendable {
case wifi = "Wi-Fi"
case cellular = "Cellular"
case ethernet = "Ethernet"
case unknown = "Unknown"
case none = "No Connection"
}
/// Network quality estimate for syncing.
public enum NetworkQuality: String, Sendable {
case excellent = "Excellent"
case good = "Good"
case fair = "Fair"
case poor = "Poor"
case none = "No Connection"
/// Suitable for large file uploads (e.g. media attachments).
public var canSyncLargeFiles: Bool { self == .excellent || self == .good }
/// Suitable for basic sync operations (small JSON documents).
public var canSync: Bool { self != .none }
}
/// Network connectivity status relevant to iCloud sync.
public struct NetworkStatus: Sendable {
public let isConnected: Bool
public let connectionType: ConnectionType
public let isExpensive: Bool
public let isConstrained: Bool
public let supportsCloudSync: Bool
public let lastChecked: Date
public init(
isConnected: Bool,
connectionType: ConnectionType,
isExpensive: Bool = false,
isConstrained: Bool = false,
supportsCloudSync: Bool = true,
lastChecked: Date = Date()
) {
self.isConnected = isConnected
self.connectionType = connectionType
self.isExpensive = isExpensive
self.isConstrained = isConstrained
self.supportsCloudSync = supportsCloudSync
self.lastChecked = lastChecked
}
public var quality: NetworkQuality {
guard isConnected else { return .none }
if isConstrained { return .poor }
switch connectionType {
case .wifi, .ethernet: return .excellent
case .cellular: return isExpensive ? .fair : .good
case .unknown, .none: return .poor
}
}
}
/// `NWPathMonitor`-based network readiness check. Two ways to use it:
///
/// - `currentStatus()` — a one-shot read for a diagnostics screen's initial
/// state, or before starting a sync operation.
/// - `statusUpdates()` — an `AsyncStream` for as long as the screen is
/// visible and iterating it. Stop iterating (or cancel the owning `Task`)
/// when the screen disappears; don't leave a monitor running for the
/// app's whole lifetime just for a diagnostics view.
public actor NetworkReadiness {
private var monitor: NWPathMonitor?
public init() {}
/// Single point-in-time read: starts a path monitor, takes the first
/// update, and cancels. Cheap enough to call every time a diagnostics
/// screen appears — don't cache this across app launches or poll it on
/// a timer (a real path change will call any *ongoing* monitor's
/// handler immediately; polling adds nothing but battery drain).
public func currentStatus() async -> NetworkStatus {
let monitor = NWPathMonitor()
return await withCheckedContinuation { continuation in
monitor.pathUpdateHandler = { path in
continuation.resume(returning: Self.buildStatus(from: path))
monitor.cancel()
}
monitor.start(queue: DispatchQueue(label: "indie-diag.network.oneshot"))
}
}
/// Ongoing updates for as long as the returned stream is iterated.
/// Only one monitor runs at a time per `NetworkReadiness` instance —
/// starting a new stream cancels any previous one.
public func statusUpdates() -> AsyncStream<NetworkStatus> {
stop()
let monitor = NWPathMonitor()
self.monitor = monitor
return AsyncStream { continuation in
monitor.pathUpdateHandler = { path in
continuation.yield(Self.buildStatus(from: path))
}
continuation.onTermination = { _ in
monitor.cancel()
}
monitor.start(queue: DispatchQueue(label: "indie-diag.network.monitor"))
}
}
/// Cancels any monitor started by `statusUpdates()`. Call this when the
/// diagnostics screen disappears if you're not relying on the stream's
/// consumer dropping out to trigger `onTermination`.
public func stop() {
monitor?.cancel()
monitor = nil
}
private nonisolated static func buildStatus(from path: NWPath) -> NetworkStatus {
let isConnected = path.status == .satisfied
let connectionType: ConnectionType
if path.usesInterfaceType(.wifi) {
connectionType = .wifi
} else if path.usesInterfaceType(.cellular) {
connectionType = .cellular
} else if path.usesInterfaceType(.wiredEthernet) {
connectionType = .ethernet
} else if isConnected {
connectionType = .unknown
} else {
connectionType = .none
}
let isExpensive = path.isExpensive
let isConstrained = path.isConstrained
// iCloud sync works best on Wi-Fi/Ethernet, or unconstrained cellular.
let supportsCloudSync = isConnected && (!isConstrained || connectionType == .wifi || connectionType == .ethernet)
return NetworkStatus(
isConnected: isConnected,
connectionType: connectionType,
isExpensive: isExpensive,
isConstrained: isConstrained,
supportsCloudSync: supportsCloudSync
)
}
}