diff --git a/Package.swift b/Package.swift index 0354651..3a0c4f9 100644 --- a/Package.swift +++ b/Package.swift @@ -4,12 +4,12 @@ import PackageDescription let package = Package( - name: "NSPersistentCloudKitContainerSyncStatus", + name: "CloudKitSyncStatus", products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( - name: "NSPersistentCloudKitContainerSyncStatus", - targets: ["NSPersistentCloudKitContainerSyncStatus"]), + name: "CloudKitSyncStatus", + targets: ["CloudKitSyncStatus"]), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -19,10 +19,10 @@ let package = Package( // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( - name: "NSPersistentCloudKitContainerSyncStatus", + name: "CloudKitSyncStatus", dependencies: []), .testTarget( - name: "NSPersistentCloudKitContainerSyncStatusTests", - dependencies: ["NSPersistentCloudKitContainerSyncStatus"]), + name: "CloudKitSyncStatusTests", + dependencies: ["CloudKitSyncStatus"]), ] ) diff --git a/README.md b/README.md index e6a1b2d..8cc3a9f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,66 @@ -# NSPersistentCloudKitContainerSyncStatus +# CloudKitSyncStatus -A description of this package. +`CloudKitSyncStatus` listens to the notifications sent out by `NSPersistentCloudKitContainer` +and translates them into a few published properties that can give your app a current state of its sync. + +The primary use for this is to detect that rare condition in which CloudKit (and therefore your app) will just stop syncing with no warning and +no notification to the user. Well, now there's an immediate warning, and you can notify the user. + +This SwiftUI view will display a red error image at the top of the screen if there's an import or export error: + + import CloudKitSyncStatus + struct SyncStatusView: View { + @available(iOS 14.0, *) + @ObservedObject var syncStatus = SyncStatus.shared + + var body: some View { + // Report only on real sync errors + if #available(iOS 14.0, *), (syncStatus.importError || syncStatus.exportError) { + VStack { + HStack { + if syncStatus.importError { + Image(systemName: "icloud.and.arrow.down").foregroundColor(.red) + } + if syncStatus.exportError { + Image(systemName: "icloud.and.arrow.up").foregroundColor(.red) + } + } + Spacer() + } + } + } + } + +`CloudKitSyncStatus` has a few "magic" properties, which are featured in the example above, and are what you +really should use. Avoid the temptation to offer a continuous "sync status", and _absolutely_ avoid the temptation to detect when "sync is +finished", as in a distributed environment (such as the one your app is creating when you use `NSPersistentCloudKitContainer`), sync is +never "finished", and you're asking for "bad things", "unpredictable results", etc if you attempt to detect "sync is finished". + +Anyway, the following properties take the state of the network into account and only say there's an error if there's an active network +connection _and_ `NSPersistentCloudKitContainer` says an import or export failed: + +- `syncError`, which tells you that something has gone wrong when nothing should be going wrong +- `importError`, which tells you that the last import failed when it shouldn't have +- `exportError`, which tells you that the last export failed when it shouldn't have + +Detecting these conditions is important because the usual "fix" for CloudKit not syncing is to delete the local database. This is fine if your +import stopped working, but if the export stopped working, this means that your user will lose any changes they made between the time the +sync failed and when it was detected. Previously, that time was based on when the user looked at two devices and noticed that they didn't +contain the same data. With `CloudKitSyncStatus`, your app can report (or act on) that failure _immediately_, saving your user's data and +your app's reputation. + +# Installation + +`CloudKitSyncStatus` is a swift package - add it to `Package.swift`: + + dependencies: [ + .package(url: "https://github.com/ggruen/CloudKitSyncStatus.git", from: "1.0.0"), + ], + targets: [ + .target( + name: "MyApp", // Where "MyApp" is the name of your app + dependencies: ["SocketConnection"]), + ] + +Or, in Xcode, you can select File » Swift Packages » Add Package Dependency... and specify the repository URL +`https://github.com/ggruen/CloudKitSyncStatus.git` and "up to next major version" `1.0.0`. diff --git a/Sources/CloudKitSyncStatus/SyncStatus.swift b/Sources/CloudKitSyncStatus/SyncStatus.swift new file mode 100644 index 0000000..494235c --- /dev/null +++ b/Sources/CloudKitSyncStatus/SyncStatus.swift @@ -0,0 +1,215 @@ +// +// SyncStatus.swift +// Starfish +// +// Created by Grant Grueninger on 9/17/20. +// Copyright © 2020 Grant Grueninger. All rights reserved. +// + +import Foundation +import CoreData +import Combine +import Network + +/// The current status of iCloud sync as reported by `NSPersistentCloudKitContainer` +/// +/// `SyncStatus` listens to the notifications sent out by `NSPersistentCloudKitContainer` +/// and translates them into a few published properties that can give your app a current state of its sync. +/// +/// The primary use for this is to detect that rare condition in which CloudKit (and therefore your app) will just stop syncing with no warning and no notification +/// to the user. Well, now there's an immediate warning, and you can notify the user. +/// +/// This SwiftUI view will display a red error image at the top of the screen if there's an import or export error: +/// +/// import CloudKitSyncStatus +/// struct SyncStatusView: View { +/// @available(iOS 14.0, *) +/// @ObservedObject var syncStatus = SyncStatus.shared +/// +/// var body: some View { +/// // Report only on real sync errors +/// if #available(iOS 14.0, *), (syncStatus.importError || syncStatus.exportError) { +/// VStack { +/// HStack { +/// if syncStatus.importError { +/// Image(systemName: "icloud.and.arrow.down").foregroundColor(.red) +/// } +/// if syncStatus.exportError { +/// Image(systemName: "icloud.and.arrow.up").foregroundColor(.red) +/// } +/// } +/// Spacer() +/// } +/// } +/// } +/// } +/// +/// `SyncStatus` has a few "magic" properties, which are featured in the example above, and are what you +/// really should use. Avoid the temptation to offer a continuous "sync status", and _absolutely_ avoid the temptation to detect when "sync is finished", +/// as in a distributed environment (such as the one `NSPersistentCloudKitContainer` is part of), sync is never "finished", and you're asking for +/// "bad things", "unpredictable results", etc if you attempt to detect "sync is finished". +/// +/// Anyway, the "magic" properties are: +/// - `syncError`, which tells you that something has gone wrong when nothing should be going wrong (i.e., there's an active network connection) +/// - `importError`, which tells you that the last import failed when it shouldn't have (i.e., there's an active network connection) +/// - `exportError`, which tells you that the last export failed when it shouldn't have (i.e., there's an active network connection) +/// +/// Detecting these conditions is important because the usual "fix" for CloudKit not syncing is to delete the local database. This is fine if your import +/// stopped working, but if the export stopped working, it means that your user will lose any changes they made between the time the sync failed and +/// when the user noticed the failure. Previously, that time was based on when the user looked at two devices and noticed that they didn't contain the same data. +/// With `SyncStatus`, your app can report (or act on) that failure _immediately_, saving your user's data and your app's reputation. +@available(iOS 14.0, macCatalyst 14.0, macOS 11.0, *) +class SyncStatus: ObservableObject { + /// A singleton to use + static let shared = SyncStatus() + + /// Status of NSPersistentCloudKitContainer setup. + /// + /// This is `nil` if NSPersistentCloudKitContainer hasn't sent a notification about a event of type `setup`, `true` if the last notification + /// of an event of type `setup` succeeded, and `false` if the last notification of an event of type `setup` failed. + @Published var setupSuccessful: Bool? = nil + + /// Status of last NSPersistentCloudKitContainer import. + /// + /// This is `nil` if NSPersistentCloudKitContainer hasn't sent a notification about a event of type `import`, `true` if the last notification + /// of an event of type `import` succeeded, and `false` if the last notification of an event of type `import` failed. + /// On failure, the `lastImportError` property will contain the localized description of + @Published var importSuccessful: Bool? = nil + + /// The localized description of the last import error, or `nil` if the last import succeeded (or no import has yet been run) + var lastImportError: String? = nil + + /// Status of last NSPersistentCloudKitContainer export. + /// + /// This is `nil` if NSPersistentCloudKitContainer hasn't sent a notification about a event of type `export`, `true` if the last notification + /// of an event of type `export` succeeded, and `false` if the last notification of an event of type `export` failed. + /// On failure, the `lastExportError` property will contain the localized description of + @Published var exportSuccessful: Bool? = nil + + /// The localized description of the last import error, or `nil` if the last import succeeded (or no import has yet been run) + var lastExportError: String? = nil + + /// Is the network available, as defined + @Published var networkAvailable: Bool? = nil + + /// Is iCloud import sync broken? + /// + /// Returns true if the network is available, NSPersistentCloudKitContainer ran an import, and the import reported an error + var importError: Bool { + return networkAvailable == true && importSuccessful == false + } + + /// Is iCloud export sync broken? + /// + /// Returns true if the network is available, NSPersistentCloudKitContainer ran an export, and the export reported an error + var exportError: Bool { + return networkAvailable == true && exportSuccessful == false + } + + /// Is iCloud sync broken? + /// + /// Returns true if the network is available and the last attempted sync (import or export) didn't succeed. + /// If this is true, your app likely needs to take some action to fix sync, e.g. clearing the local cache, quitting/restarting, etc. + /// See importError or exportError for the error. + var syncError: Bool { + return importError || exportError + } + + /// Where we store Combine cancellables for publishers we're listening to, e.g. NSPersistentCloudKitContainer's notifications. + fileprivate var disposables = Set() + + /// Network path monitor that's used to track whether we can reach the network at all + // fileprivate let monitor: NetworkMonitor = NWPathMonitor() + fileprivate let monitor = NWPathMonitor() + + /// The queue on which we'll run our network monitor + fileprivate let monitorQueue = DispatchQueue(label: "NetworkMonitor") + + /// Creates a SyncStatus with values set manually and doesn't listen for NSPersistentCloudKitContainer notifications (for testing/previews) + init(setupSuccessful: Bool? = nil, importSuccessful: Bool? = nil, exportSuccessful: Bool? = nil, + networkAvailable: Bool? = nil) { + self.setupSuccessful = setupSuccessful + self.importSuccessful = importSuccessful + self.exportSuccessful = exportSuccessful + self.networkAvailable = networkAvailable + } + + init() { + // XCode 12 is reporting that "eventChangedNotification" doesn't exist when compiling on Mac even with the + // @available set for the class. Temporary hack to let it compile on Mac. + // Fixed in Xcode 12.2 beta, but I'm leaving this commented out in case I need to add it back to do a release. + // #if !targetEnvironment(macCatalyst) + NotificationCenter.default.publisher(for: NSPersistentCloudKitContainer.eventChangedNotification) + .debounce(for: 1, scheduler: DispatchQueue.main) + .sink(receiveValue: { notification in + if let cloudEvent = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] + as? NSPersistentCloudKitContainer.Event { + // This translation to our "SyncEvent" lets us write unit tests, since + // NSPersistentCloudKitContainer.Event's properties are read-only (meaning we can't fire off a + // fake one). + let event = SyncEvent(from: cloudEvent) + self.setProperties(from: event) + } + }) + .store(in: &disposables) + // #endif + + // Update the network status when the OS reports a change. Note that we ignore whether the connection is + // expensive or not - we just care whether iCloud is _able_ to sync. If there's no network, + // NSPersistentCloudKitContainer will try to sync but report an error. We consider that a real error unless + // the network is not available at all. If it's available but expensive, it's still an error. + // Obstensively, if the user's device has iCloud syncing turned off (e.g. due to low power mode or not + // allowing syncing over cellular connections), NSPersistentCloudKitContainer won't try to sync. + // If that assumption is incorrect, we'll need to update the logic in this class. + monitor.pathUpdateHandler = { path in + DispatchQueue.main.async { self.networkAvailable = (path.status == .satisfied) } + } + monitor.start(queue: monitorQueue) + } + + deinit { + // Clean up our listeners, just to be neat + monitor.cancel() + for cancellable in disposables { + cancellable.cancel() + } + + } + + /// Sets the status properties based on the information in the provided sync event + func setProperties(from event: SyncEvent) { + switch event.type { + case .import: + self.importSuccessful = event.succeeded + self.lastImportError = event.error?.localizedDescription + case .setup: + self.setupSuccessful = event.succeeded + case .export: + self.exportSuccessful = event.succeeded + self.lastExportError = event.error?.localizedDescription + @unknown default: + assertionFailure("New event type added to NSPersistenCloudKitContainer") + } + } + + /// A sync event containing the values from NSPersistentCloudKitContainer.Event that we track + struct SyncEvent { + var type: NSPersistentCloudKitContainer.EventType + var succeeded: Bool + var error: Error? + + /// Creates a SyncEvent from explicitly provided values (for testing) + init(type: NSPersistentCloudKitContainer.EventType, succeeded: Bool, error: Error) { + self.type = type + self.succeeded = succeeded + self.error = error + } + + /// Creates a SyncEvent from an NSPersistentCloudKitContainer Event + init(from cloudKitEvent: NSPersistentCloudKitContainer.Event) { + self.type = cloudKitEvent.type + self.succeeded = cloudKitEvent.succeeded + self.error = cloudKitEvent.error + } + } +} diff --git a/Sources/NSPersistentCloudKitContainerSyncStatus/NSPersistentCloudKitContainerSyncStatus.swift b/Sources/NSPersistentCloudKitContainerSyncStatus/NSPersistentCloudKitContainerSyncStatus.swift deleted file mode 100644 index fc82d5b..0000000 --- a/Sources/NSPersistentCloudKitContainerSyncStatus/NSPersistentCloudKitContainerSyncStatus.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct NSPersistentCloudKitContainerSyncStatus { - var text = "Hello, World!" -} diff --git a/Tests/CloudKitSyncStatusTests/SyncStatusTests.swift b/Tests/CloudKitSyncStatusTests/SyncStatusTests.swift new file mode 100644 index 0000000..e57e69a --- /dev/null +++ b/Tests/CloudKitSyncStatusTests/SyncStatusTests.swift @@ -0,0 +1,55 @@ +import XCTest +import CoreData +@testable import CloudKitSyncStatus + +@available(iOS 14.0, *) +final class SyncStatusTests: XCTestCase { + func testCanDetectImportError() { + // Given an active network connection + let syncStatus = SyncStatus(setupSuccessful: nil, importSuccessful: nil, + exportSuccessful: nil, networkAvailable: true) + + // When NSPersistentCloudKitContainer reports an unsuccessful import + let errorText = "I don't like clouds" + let error = NSError(domain: errorText, code: 0, userInfo: nil) + let event = SyncStatus.SyncEvent(type: .import, succeeded: false, + error: error) + syncStatus.setProperties(from: event) + + // Then importError is true + XCTAssertTrue(syncStatus.importError) + + // and importSuccessful is false + XCTAssert(syncStatus.importSuccessful == false) + + // and the error's localized description is "I don't like clouds" + XCTAssertEqual(syncStatus.lastImportError, error.localizedDescription) + } + + func testCanDetectExportError() { + // Given an active network connection + let syncStatus = SyncStatus(setupSuccessful: nil, importSuccessful: nil, + exportSuccessful: nil, networkAvailable: true) + + // When NSPersistentCloudKitContainer reports an unsuccessful import + let errorText = "I don't like clouds" + let error = NSError(domain: errorText, code: 0, userInfo: nil) + let event = SyncStatus.SyncEvent(type: .export, succeeded: false, + error: error) + syncStatus.setProperties(from: event) + + // Then exportError is true + XCTAssertTrue(syncStatus.exportError) + + // and exportSuccessful is false + XCTAssert(syncStatus.exportSuccessful == false) + + // and the error's localized description is "I don't like clouds" + XCTAssertEqual(syncStatus.lastExportError, error.localizedDescription) + } + + static var allTests = [ + ("testCanDetectImportError", testCanDetectImportError), + ("testCanDetectExportError", testCanDetectExportError), + ] +} diff --git a/Tests/NSPersistentCloudKitContainerSyncStatusTests/XCTestManifests.swift b/Tests/CloudKitSyncStatusTests/XCTestManifests.swift similarity index 61% rename from Tests/NSPersistentCloudKitContainerSyncStatusTests/XCTestManifests.swift rename to Tests/CloudKitSyncStatusTests/XCTestManifests.swift index 0becd0b..d7921be 100644 --- a/Tests/NSPersistentCloudKitContainerSyncStatusTests/XCTestManifests.swift +++ b/Tests/CloudKitSyncStatusTests/XCTestManifests.swift @@ -3,7 +3,7 @@ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { return [ - testCase(NSPersistentCloudKitContainerSyncStatusTests.allTests), + testCase(SyncStatusTests.allTests), ] } #endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index b43b998..5ec69bf 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,7 +1,7 @@ import XCTest -import NSPersistentCloudKitContainerSyncStatusTests +import CloudKitSyncStatusTests var tests = [XCTestCaseEntry]() -tests += NSPersistentCloudKitContainerSyncStatusTests.allTests() +tests += SyncStatusTests.allTests() XCTMain(tests) diff --git a/Tests/NSPersistentCloudKitContainerSyncStatusTests/NSPersistentCloudKitContainerSyncStatusTests.swift b/Tests/NSPersistentCloudKitContainerSyncStatusTests/NSPersistentCloudKitContainerSyncStatusTests.swift deleted file mode 100644 index 3fe72e0..0000000 --- a/Tests/NSPersistentCloudKitContainerSyncStatusTests/NSPersistentCloudKitContainerSyncStatusTests.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest -@testable import NSPersistentCloudKitContainerSyncStatus - -final class NSPersistentCloudKitContainerSyncStatusTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(NSPersistentCloudKitContainerSyncStatus().text, "Hello, World!") - } - - static var allTests = [ - ("testExample", testExample), - ] -}