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
165 lines
5.9 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|