| 1 | import Foundation |
| 2 | |
| 3 | /// A failure the person has to see and act on. |
| 4 | public enum SyncFailure: Sendable, Equatable { |
| 5 | /// An entry is held; the app owns the wording and the action. |
| 6 | case held(Hold.Reason) |
| 7 | /// Enqueueing failed (disk full, or the store is unreadable): input pauses. |
| 8 | case cannotSaveOnDevice |
| 9 | /// The app's per-lane byte ceiling was reached: input stops, and says why. |
| 10 | case tooManyUnsent |
| 11 | } |
| 12 | |
| 13 | /// What the save status says, computed from outbox state and never from HTTP |
| 14 | /// activity. The cases are in priority order: the first that applies wins. |
| 15 | /// |
| 16 | /// "Offline" with nothing pending is `.saved`: status stays silent and the |
| 17 | /// connectivity chrome says offline, so "Offline · 0 pending" never appears. |
| 18 | public enum SyncStatus: Sendable, Equatable { |
| 19 | /// 1. A held entry, or a local failure. Sticky until resolved. |
| 20 | case failed(pending: Int, reason: SyncFailure) |
| 21 | /// 2. The session ended with changes still to send. Sticky until sign-in. |
| 22 | case needsSignIn(pending: Int) |
| 23 | /// 3. Nothing to send and nothing in flight. Silent. |
| 24 | case saved |
| 25 | /// 4. A send is in flight (or about to start). |
| 26 | case saving |
| 27 | /// 5. Changes are waiting for a connection. |
| 28 | case offline(pending: Int) |
| 29 | /// 6. Backing off after a transient failure. |
| 30 | case retrying(pending: Int) |
| 31 | |
| 32 | public enum SaveState: String, Sendable { case saved, saving, retrying, failed } |
| 33 | public var saveState: SaveState { |
| 34 | switch self { |
| 35 | case .saved: return .saved |
| 36 | case .saving: return .saving |
| 37 | case .offline, .retrying: return .retrying |
| 38 | case .failed, .needsSignIn: return .failed |
| 39 | } |
| 40 | } |
| 41 | public var isSticky: Bool { saveState == .failed } |
| 42 | public var isAnnounced: Bool { |
| 43 | switch self { case .offline, .needsSignIn, .failed: return true; default: return false } |
| 44 | } |
| 45 | public init(summary: OutboxSummary, sending: Bool, sessionEnded: Bool, offline: Bool, backingOff: Bool, |
| 46 | localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) { |
| 47 | let pending = count(summary) |
| 48 | if let held = summary.lanes.sorted(by: { $0.key < $1.key }).compactMap({ $0.value.firstHold }).first { |
| 49 | self = .failed(pending: pending, reason: .held(held)) |
| 50 | } else if let localFailure { self = .failed(pending: pending, reason: localFailure) } |
| 51 | else if summary.unavailable { self = .failed(pending: pending, reason: .cannotSaveOnDevice) } |
| 52 | else if sessionEnded { self = .needsSignIn(pending: pending) } |
| 53 | else if summary.isEmpty && summary.inFlight == 0 { self = .saved } |
| 54 | else if sending || summary.inFlight > 0 { self = .saving } |
| 55 | else if offline { self = .offline(pending: pending) } |
| 56 | else if backingOff { self = .retrying(pending: pending) } |
| 57 | else { self = .saving } |
| 58 | } |
| 59 | } |
| 60 | |