[
  {
    "path": ".gitignore",
    "content": ".DS_Store\n/.build\n/Packages\n/*.xcodeproj\nxcuserdata/\n"
  },
  {
    "path": ".swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Workspace\n   version = \"1.0\">\n   <FileRef\n      location = \"self:\">\n   </FileRef>\n</Workspace>\n"
  },
  {
    "path": ".swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>IDEDidComputeMac32BitWarning</key>\n\t<true/>\n</dict>\n</plist>\n"
  },
  {
    "path": "LICENSE.txt",
    "content": "Copyright (c) 2020 Grant Grueninger\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Package.swift",
    "content": "// swift-tools-version: 6.0\n// The swift-tools-version declares the minimum version of Swift required to build this package.\n\nimport PackageDescription\n\nlet package = Package(\n    name: \"CloudKitSyncMonitor\",\n    defaultLocalization: \"en\",\n    platforms: [.macOS(.v12), .iOS(.v15), .watchOS(.v8), .tvOS(.v15), .macCatalyst(.v15)],\n    products: [\n        // Products define the executables and libraries a package produces, and make them visible to other packages.\n        .library(\n            name: \"CloudKitSyncMonitor\",\n            targets: [\"CloudKitSyncMonitor\"]),\n    ],\n    dependencies: [\n        // Dependencies declare other packages that this package depends on.\n        // .package(url: /* package url */, from: \"1.0.0\"),\n    ],\n    targets: [\n        // Targets are the basic building blocks of a package. A target can define a module or a test suite.\n        // Targets can depend on other targets in this package, and on products in packages this package depends on.\n        .target(\n            name: \"CloudKitSyncMonitor\",\n            dependencies: [],\n            resources: [\n                .process(\"Localizable.xcstrings\")\n            ]),\n        .testTarget(\n            name: \"CloudKitSyncMonitorTests\",\n            dependencies: [\"CloudKitSyncMonitor\"]),\n    ],\n    swiftLanguageModes: [.v5, .v6]\n)\n"
  },
  {
    "path": "README.md",
    "content": "# CloudKitSyncMonitor\n\n`CloudKitSyncMonitor` is a Swift package that listens to notifications sent out by `NSPersistentCloudKitContainer` and translates them into published properties, providing your app with real-time sync state information.\n\nThis package addresses a critical issue where CloudKit (and consequently your app) may cease syncing without warning or user notification. `CloudKitSyncMonitor` offers immediate detection of such scenarios, allowing you to promptly inform users and take appropriate action.\n\n[![Swift Version](https://img.shields.io/badge/Swift-6.0-orange)](https://swift.org)\n[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![Platform](https://img.shields.io/badge/platform-iOS%20%7C%20macOS-lightgrey.svg)](https://developer.apple.com/)\n[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com)\n\n## Features and Behavior 🌟\n\n### Core Functionality 🛠️\n\n- 📡 Monitors sync status by intercepting and interpreting `NSPersistentCloudKitContainer` notifications\n- 🧠 Intelligently assesses sync health by considering both network availability and iCloud account status\n- 🔍 Exposes a `SyncMonitor` class, conveniently accessible via the `SyncMonitor.default` singleton\n\n### Notification Subscriptions 📬\n\n`SyncMonitor` actively subscribes to notifications from key system components:\n- 🔄 `NSPersistentCloudKitContainer`: For core sync event monitoring\n- ☁️ `CKContainer`: To track CloudKit-specific states\n- 🌐 `NWPathMonitor`: For network status updates\n\n**Important:** ⚠️ To ensure accurate and timely state information, call `SyncMonitor.default.startMonitoring()` as early as possible in your app's lifecycle, preferably in your app delegate or initial view.\n\n### Information Levels 📊\n\n`SyncMonitor` provides sync information at two distinct levels of granularity:\n\n#### Top Level\nThe `syncStateSummary` property offers a high-level enum summarizing the overall sync state. This is ideal for quick status checks and user-facing information.\n\n#### Detailed Level\n`SyncMonitor` tracks the states of `NSPersistentCloudKitContainer`'s three primary event types:\n   - Setup: Initialization of the sync environment\n   - Import: Incoming data from CloudKit to the local store\n   - Export: Outgoing data from the local store to CloudKit\n\n   To monitor these events, `SyncMonitor` provides corresponding properties:\n   - `setupState`: Tracks the state of the setup event\n   - `importState`: Monitors the state of the import event\n   - `exportState`: Follows the state of the export event\n   \n   These properties provide comprehensive information about each sync phase, including convenience methods for extracting commonly needed details.\n\n### Problem Detection 🚨\n\n`SyncMonitor` offers robust tools for identifying sync issues:\n\n#### General Detection\n- 🔴 `hasSyncError`: A Boolean indicating the presence of any sync-related error\n- 🟡 `isNotSyncing`: Detects scenarios where sync should be operational but isn't functioning as expected\n\n#### Specific Error Information\n- `setupError`: Captures issues during the sync setup phase\n- `importError`: Identifies problems with data import from CloudKit\n- `exportError`: Highlights issues when exporting data to CloudKit\n\n### Special Properties 🔑\n\nThe `isNotSyncing` property is particularly useful for detecting subtle sync issues:\n- It indicates when setup has completed successfully, but no import event has started, and no errors have been reported\n- This can reveal edge cases like OS-level password re-entry prompts, where CloudKit considers the account \"available\", but `NSPersistentCloudKitContainer` is unable to initiate sync\n- Like other properties, it factors in network availability and iCloud account status for accurate reporting\n\n### Importance of Error Detection ⚠️\n\nTimely and accurate error detection is crucial for maintaining data integrity and user trust:\n\n1. 🛡️ Prevents potential data loss by identifying sync failures before they lead to conflicts or data divergence\n2. ⚡ Enables immediate detection and reporting of sync anomalies, often before users notice any issues\n3. 😊 Significantly enhances user experience by providing transparent, real-time sync status information\n4. 🏆 Helps maintain app reliability and data consistency across devices\n\n### Detailed Sync Information 📋\n\nThe `setupState`, `importState`, and `exportState` properties offer comprehensive insights into the sync process:\n- Current state of each event type (not started, in progress, succeeded, or failed)\n- Precise start and end times for each sync event\n- Detailed error information when applicable\n\nExample usage for displaying detailed sync status:\n\n```swift\nfileprivate var dateFormatter: DateFormatter = {\n    let dateFormatter = DateFormatter()\n    dateFormatter.dateStyle = .short\n    dateFormatter.timeStyle = .short\n    return dateFormatter\n}()\n\nprint(\"Setup state: \\(stateText(for: SyncMonitor.default.setupState))\")\nprint(\"Import state: \\(stateText(for: SyncMonitor.default.importState))\")\nprint(\"Export state: \\(stateText(for: SyncMonitor.default.exportState))\")\n\nfunc stateText(for state: SyncMonitor.SyncState) -> String {\n    switch state {\n    case .notStarted:\n        return \"Not started\"\n    case .inProgress(started: let date):\n        return \"In progress since \\(dateFormatter.string(from: date))\"\n    case let .succeeded(started: _, ended: endDate):\n        return \"Succeeded at \\(dateFormatter.string(from: endDate))\"\n    case let .failed(started: _, ended: endDate, error: _):\n        return \"Failed at \\(dateFormatter.string(from: endDate))\"\n    }\n}\n```\n\nFor more detailed information on all available properties and methods, please refer to the comprehensive SyncMonitor documentation.\n\n## Usage Examples 🚀\n\n### Handle Errors\n\n```swift\nprivate let syncMonitor = SyncMonitor.default\n\nif syncMonitor.hasSyncError {\n    if let error = syncMonitor.setupError {\n        print(\"Unable to set up iCloud sync, changes won't be saved! \\(error.localizedDescription)\")\n    }\n    if let error = syncMonitor.importError {\n        print(\"Import is broken: \\(error.localizedDescription)\")\n    }\n    if let error = syncMonitor.exportError {\n        print(\"Export is broken - your changes aren't being saved! \\(error.localizedDescription)\")\n    }\n} else if syncMonitor.isNotSyncing {\n    print(\"Sync should be working, but isn't. Look for a badge on Settings or other possible issues.\")\n}\n```\n\n### Display Error Status\n\n```swift\nimport SwiftUI\nimport CloudKitSyncMonitor\n\nstruct SyncStatusView: View {\n    \n    @StateObject private var syncMonitor = SyncMonitor.default\n\n    var body: some View {\n         if syncMonitor.syncStateSummary.isBroken {\n             Image(systemName: syncMonitor.syncStateSummary.symbolName)\n                 .foregroundColor(syncMonitor.syncStateSummary.symbolColor)\n         }\n    }\n}\n```\n\n### Display Current Sync State\n\n```swift\nimport SwiftUI\nimport CloudKitSyncMonitor\n\nstruct SyncStatusView: View {\n    \n    @StateObject private var syncMonitor = SyncMonitor.default\n\n    var body: some View {\n        Image(systemName: syncMonitor.syncStateSummary.symbolName)\n            .foregroundColor(syncMonitor.syncStateSummary.symbolColor)\n    }\n}\n```\n\n### Conditional Display\n\n```swift\nif syncMonitor.syncStateSummary.isBroken || syncMonitor.syncStateSummary.isInProgress {\n    Image(systemName: syncMonitor.syncStateSummary.symbolName)\n        .foregroundColor(syncMonitor.syncStateSummary.symbolColor)\n}\n```\n\n### Check for Specific States\n\n```swift\nif case .accountNotAvailable = syncMonitor.syncStateSummary {\n    Text(\"Hey, log into your iCloud account if you want to sync\")\n}\n```\n\n## Installation 📦\n\n### Swift Package Manager\n\nAdd the following to your `Package.swift`:\n\n```swift\ndependencies: [\n    .package(url: \"https://github.com/ggruen/CloudKitSyncMonitor.git\", from: \"3.0.0\"),\n],\ntargets: [\n    .target(\n        name: \"MyApp\", // Where \"MyApp\" is the name of your app\n        dependencies: [\"CloudKitSyncMonitor\"]),\n]\n```\n\n### Xcode\n\n1. Select File » Swift Packages » Add Package Dependency...\n2. Enter the repository URL: `https://github.com/ggruen/CloudKitSyncMonitor.git`\n3. Choose \"Up to next major version\" with `3.0.0` as the minimum version.\n\n## Development 🛠️\n\n- 🍴 Fork repository\n- 📥 Check out on your development system\n- 📁 Drag the folder this README is in (CloudKitSyncMonitor) into your Xcode project or workspace. This will make Xcode choose the\n  local version over the version in the package manager.\n- 🔧 If you haven't added it via File > Swift Packages already, go into your project > General tab > Frameworks, Libraries and Embedded Content,\n  and click the + button to add it. You may need to quit and re-start Xcode to make the new package appear in the list so you can select it.\n- 🖊️ Make your changes, commit and push to Github\n- 🚀 Submit pull request\n\nTo go back to using the github version, just remove CloudKitSyncMonitor (click on it, hit the delete key, choose to remove reference)\nfrom the side bar - Xcode should fall back to the version you added using the Installation instructions above. If you _haven't_ installed\nit as a package dependency yet, then just delete it from the side bar and then add it as a package dependency using the Installation\ninstructions above.\n\nYou can also submit issues if you find bugs, have suggestions or questions, etc. 🐛💡❓\n"
  },
  {
    "path": "Sources/CloudKitSyncMonitor/Localizable.xcstrings",
    "content": "{\n  \"sourceLanguage\" : \"en\",\n  \"strings\" : {\n    \"Error\" : {\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"خطأ\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Fehler\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Error\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Erreur\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"एरर\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Errore\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"エラー\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"오류\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Hata\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"错误\"\n          }\n        }\n      }\n    },\n    \"No iCloud account\" : {\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"لا يوجد حساب iCloud\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Kein iCloud-Konto\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Sin cuenta de iCloud\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Pas de compte iCloud\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"कोई iCloud खाता नहीं\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Nessun account iCloud\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iCloudアカウントが見つかりません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"iCloud 계정 없음\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iCloud hesabı yok\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"没有 iCloud 账户\"\n          }\n        }\n      }\n    },\n    \"No network available\" : {\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"لا توجد شبكة متاحة\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Kein Netzwerk verfügbar\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"No hay red disponible\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Pas de réseau disponible\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"कोई नेटवर्क उपलब्ध नहीं है\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Rete non disponibile\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"ネットワークが利用できません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"사용 가능한 네트워크 없음\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"İnternet bağlantısı yok\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无网络可用\"\n          }\n        }\n      }\n    },\n    \"Not syncing to iCloud\" : {\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"عدم المزامنة مع iCloud\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Synchronisiert nicht mit iCloud\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"No se sincroniza con iCloud\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Pas de synchronisation avec iCloud\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"iCloud से सिंक नहीं हो रहा है\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Non si sincronizza con iCloud\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iCloudに同期できません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"iCloud에 동기화되지 않음\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iCloud ile eşzamanlanmıyor\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"无法同步到 iCloud\"\n          }\n        }\n      }\n    },\n    \"Sync not started\" : {\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"لم تبدأ المزامنة\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Synchronisation nicht gestartet\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Sincronización no iniciada\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"La synchronisation n'a pas commencé\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"सिंक प्रारंभ नहीं हुआ\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"La sincronizzazione non è stata avviata\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"同期が開始されていません\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"동기화가 시작되지 않음\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Eşzamanlama başlatılmadı\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"同步未启动\"\n          }\n        }\n      }\n    },\n    \"Synced with iCloud\" : {\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"متزامن مع iCloud\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Mit iCloud synchronisiert\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Sincronizado con iCloud\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Synchronisé avec iCloud\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"iCloud के साथ समन्वयित\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Sincronizzato con iCloud\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iCloudと同期されました\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"iCloud와 동기화\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"iCloud ile eşzamanlandı\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"与 iCloud 同步\"\n          }\n        }\n      }\n    },\n    \"Syncing...\" : {\n      \"localizations\" : {\n        \"ar\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"المزامنه...\"\n          }\n        },\n        \"de\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Synchronisieren...\"\n          }\n        },\n        \"es\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Sincronización...\"\n          }\n        },\n        \"fr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Synchronisation...\"\n          }\n        },\n        \"hi\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"सिंक्रनाइज़।।।\"\n          }\n        },\n        \"it\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"Sincronizzazione...\"\n          }\n        },\n        \"ja\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"同期中...\"\n          }\n        },\n        \"ko\" : {\n          \"stringUnit\" : {\n            \"state\" : \"needs_review\",\n            \"value\" : \"동기화...\"\n          }\n        },\n        \"tr\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"Eşzamanlanıyor…\"\n          }\n        },\n        \"zh-Hans\" : {\n          \"stringUnit\" : {\n            \"state\" : \"translated\",\n            \"value\" : \"同步进行中...\"\n          }\n        }\n      }\n    }\n  },\n  \"version\" : \"1.1\"\n}"
  },
  {
    "path": "Sources/CloudKitSyncMonitor/SyncMonitor.swift",
    "content": "//\n//  SyncMonitor.swift\n//\n//\n//  Created by Grant Grueninger on 9/23/20.\n//  Updated by JP Toro on 9/28/24.\n//\n\nimport CoreData\nimport Network\nimport SwiftUI\nimport CloudKit\n\n/// A class that monitors and reports the current state of `NSPersistentCloudKitContainer` synchronization.\n///\n/// `SyncMonitor` provides a comprehensive overview of the iCloud sync status for your Core Data stack using `NSPersistentCloudKitContainer`. It tracks setup, import, and export events, network availability, and iCloud account status to give you detailed insights into the sync process.\n///\n/// While `NSPersistentCloudKitContainer` typically handles synchronization automatically, `SyncMonitor` is particularly useful for detecting and responding to sync failures, which could potentially lead to data loss if not addressed.\n///\n/// - Important: Call `SyncMonitor.default.startMonitoring()` early in your app to promptly set up monitoring. Otherwise, monitoring will begin when `SyncMonitor.default` is first accessed, which could lead to inaccurate state information if properties are checked right away.\n///\n/// - Note: iCloud is considered the \"source of truth\" for `NSPersistentCloudKitContainer` data. A sync failure, especially during export, may result in local changes not being propagated to iCloud.\n///\n/// # Usage\n///\n/// ```swift\n/// if SyncMonitor.default.hasSyncError {\n///     if let error = SyncMonitor.default.setupError {\n///         print(\"iCloud sync setup failed: \\(error.localizedDescription)\")\n///     }\n///     if let error = SyncMonitor.default.importError {\n///         print(\"iCloud import failed: \\(error.localizedDescription)\")\n///     }\n///     if let error = SyncMonitor.default.exportError {\n///         print(\"iCloud export failed: \\(error.localizedDescription)\")\n///     }\n/// } else if SyncMonitor.default.isNotSyncing {\n///     print(\"Sync should be working, but isn't. Check for system-level issues.\")\n/// }\n/// ```\n///\n/// # SwiftUI Integration\n///\n/// Observe `SyncMonitor` in your SwiftUI views:\n///\n/// ```swift\n/// @StateObject private var syncMonitor = SyncMonitor.default\n/// ```\n///\n/// Display a sync status icon:\n///\n/// ```swift\n/// Image(systemName: syncMonitor.syncStateSummary.symbolName)\n///     .foregroundColor(syncMonitor.syncStateSummary.symbolColor)\n/// ```\n///\n/// Show an icon only for sync errors:\n///\n/// ```swift\n/// if syncMonitor.syncStateSummary.isBroken {\n///     Image(systemName: syncMonitor.syncStateSummary.symbolName)\n///         .foregroundColor(syncMonitor.syncStateSummary.symbolColor)\n/// }\n/// ```\n///\n/// Display an icon during active syncing:\n///\n/// ```swift\n/// if syncMonitor.syncStateSummary.isInProgress {\n///     Image(systemName: syncMonitor.syncStateSummary.symbolName)\n///         .foregroundColor(syncMonitor.syncStateSummary.symbolColor)\n/// }\n/// ```\n///\n/// # Detailed Error Reporting\n///\n/// For a more granular view of sync status:\n///\n/// ```swift\n/// Group {\n///     if syncMonitor.hasSyncError {\n///         VStack {\n///             HStack {\n///                 if syncMonitor.setupError != nil {\n///                     Image(systemName: \"xmark.icloud\").foregroundColor(.red)\n///                 }\n///                 if syncMonitor.importError != nil {\n///                     Image(systemName: \"icloud.and.arrow.down\").foregroundColor(.red)\n///                 }\n///                 if syncMonitor.exportError != nil {\n///                     Image(systemName: \"icloud.and.arrow.up\").foregroundColor(.red)\n///                 }\n///             }\n///         }\n///     } else if syncMonitor.isNotSyncing {\n///         Image(systemName: \"xmark.icloud\")\n///     } else {\n///         Image(systemName: \"icloud\").foregroundColor(.green)\n///     }\n/// }\n/// ```\n@MainActor\npublic class SyncMonitor: ObservableObject {\n    /// The shared instance of `SyncMonitor`.\n    public static let `default` = SyncMonitor()\n    \n    @available(*, deprecated, renamed: \"default\")\n    public static var shared: SyncMonitor { `default` }\n    \n    // MARK: - Summary properties -\n    \n    /// A summary of the overall sync state.\n    ///\n    /// This property provides a high-level overview of the current sync status, which can be used to display a summary icon or message in your user interface.\n    ///\n    /// The sync state summary is determined as follows:\n    /// - `.noNetwork`: The network is unavailable.\n    /// - `.accountNotAvailable`: The iCloud account is not available (e.g., user not logged in or iCloud is disabled for the app).\n    /// - `.error`: An error was reported for any event type during the last sync attempt.\n    /// - `.notSyncing`: Sync should be working but isn't (see `isNotSyncing`).\n    /// - `.notStarted`: All event types are in the `.notStarted` state.\n    /// - `.inProgress`: At least one event type is in the `.inProgress` state.\n    /// - `.succeeded`: All event types are in the `.succeeded` state.\n    /// - `.unknown`: The sync state doesn't match any of the above conditions.\n    ///\n    /// - Returns: A `SyncSummaryStatus` enum value representing the current state of sync.\n    public var syncStateSummary: SyncSummaryStatus {\n        if isNetworkAvailable == false { return .noNetwork }\n        if let iCloudAccountStatus, iCloudAccountStatus != .available { return .accountNotAvailable }\n        if hasSyncError { return .error }\n        if isNotSyncing { return .notSyncing }\n        if case .notStarted = importState, case .notStarted = exportState, case .notStarted = setupState {\n            return .notStarted\n        }\n        \n        if case .inProgress = setupState { return .inProgress }\n        if case .inProgress = importState { return .inProgress }\n        if case .inProgress = exportState { return .inProgress }\n        \n        if case .succeeded = importState, case .succeeded = exportState {\n            return .succeeded\n        }\n        return .unknown\n    }\n    \n    @available(*, deprecated, renamed: \"hasSyncError\")\n    public var syncError: Bool { hasSyncError }\n    \n    /// Indicates whether `NSPersistentCloudKitContainer` has reported any sync errors.\n    ///\n    /// This property returns `true` if `setupError`, `importError`, or `exportError` is not `nil`.\n    ///\n    /// - Returns: A Boolean value indicating whether a sync error has been reported.\n    public var hasSyncError: Bool {\n        return setupError != nil || importError != nil || exportError != nil\n    }\n    \n    /// Indicates whether sync should be functioning normally.\n    ///\n    /// Returns `true` if the user's iCloud account is available, the network is available, there are no recorded sync errors, and setup has completed successfully.\n    ///\n    /// - Returns: A Boolean value indicating whether sync should be operational.\n    public var shouldBeSyncing: Bool {\n        if case .available = iCloudAccountStatus, self.isNetworkAvailable == true, !hasSyncError,\n           case .succeeded = setupState {\n            return true\n        }\n        return false\n    }\n    \n    @available(*, deprecated, renamed: \"isNotSyncing\")\n    public var notSyncing: Bool { isNotSyncing }\n    \n    /// Detects a condition where CloudKit should be syncing but isn't.\n    ///\n    /// This property returns `true` if `shouldBeSyncing` is `true` but `importState` is still `.notStarted`.\n    ///\n    /// - Note: This condition should typically only occur briefly (less than a second) between setup completion and the start of the first import.\n    ///\n    /// - Important: If `isNotSyncing` remains `true` for an extended period, it may indicate an issue with `NSPersistentCloudKitContainer`. Consider filing a Feedback to Apple if you encounter this situation.\n    public var isNotSyncing: Bool {\n        if case .notStarted = importState, shouldBeSyncing {\n            return true\n        }\n        return false\n    }\n    \n    /// The error encountered during the CloudKit setup process, if any.\n    ///\n    /// If not `nil`, this indicates a significant problem that may prevent imports or exports from occurring, potentially leading to sync delays.\n    ///\n    /// - Returns: An `Error` object if there's a setup error, or `nil` if there's no error.\n    public var setupError: Error? {\n        if isNetworkAvailable == true, let error = setupState.error {\n            return error\n        }\n        return nil\n    }\n    \n    /// The error encountered during the CloudKit import process, if any.\n    ///\n    /// - Returns: An `Error` object if there's an import error, or `nil` if there's no error.\n    public var importError: Error? {\n        if isNetworkAvailable == true, let error = importState.error {\n            return error\n        }\n        return nil\n    }\n    \n    /// The error encountered during the CloudKit export process, if any.\n    ///\n    /// - Returns: An `Error` object if there's an export error, or `nil` if there's no error.\n    ///\n    /// - Important: An export error is particularly critical as it may result in local changes not being synchronized to iCloud. Prompt detection and correction of export errors is crucial for data integrity.\n    public var exportError: Error? {\n        if isNetworkAvailable == true, let error = exportState.error {\n            return error\n        }\n        return nil\n    }\n    \n    // MARK: - Specific Status Properties -\n    \n    /// The current state of the `NSPersistentCloudKitContainer` setup process.\n    @Published public private(set) var setupState: SyncState = .notStarted\n    \n    /// The current state of the `NSPersistentCloudKitContainer` import process.\n    @Published public private(set) var importState: SyncState = .notStarted\n    \n    /// The current state of the `NSPersistentCloudKitContainer` export process.\n    @Published public private(set) var exportState: SyncState = .notStarted\n    \n    @available(*, deprecated, renamed: \"isNetworkAvailable\")\n    public var networkAvailable: Bool? { isNetworkAvailable }\n    \n    /// Indicates whether the network is available for iCloud sync.\n    ///\n    /// - Returns: A Boolean value indicating network availability, or `nil` if the status is unknown.\n    @Published public private(set) var isNetworkAvailable: Bool? = nil\n    \n    /// The current status of the user's iCloud account.\n    ///\n    /// This property is automatically updated if the account status changes.\n    @Published public private(set) var iCloudAccountStatus: CKAccountStatus?\n    \n    @available(*, deprecated, renamed: \"iCloudAccountStatusError\")\n    public var iCloudAccountStatusUpdateError: Error? { iCloudAccountStatusError }\n    \n    /// The error encountered when attempting to retrieve the user's iCloud account status, if any.\n    public private(set) var iCloudAccountStatusError: Error?\n    \n    // MARK: - Diagnosis properties -\n    \n    @available(*, deprecated, renamed: \"lastSyncError\")\n    public var lastError: Error? { lastSyncError }\n    \n    /// The most recent sync error encountered.\n    ///\n    /// This property can be useful for diagnosing issues related to `isNotSyncing` or other \"partial\" errors that CloudKit believes it recovered from but didn't.\n    public private(set) var lastSyncError: Error?\n    \n    // MARK: - Private properties -\n    \n    /// Network path monitor that's used to track whether we can reach the network at all\n    private let monitor = NWPathMonitor()\n    \n    /// Task for managing all monitoring activities\n    private var monitoringTask: Task<Void, Error>?\n    \n    // MARK: - Initializers -\n    \n    private init() {\n        _startMonitoring()\n    }\n    \n    deinit {\n        monitoringTask?.cancel()\n    }\n    \n    #if DEBUG\n    /// Convenience initializer that creates a `SyncMonitor` with preset state values for testing or previews\n    ///\n    /// - Parameters:\n    ///   - isSetupSuccessful: A Boolean value indicating whether the setup was successful. Defaults to `true`.\n    ///   - isImportSuccessful: A Boolean value indicating whether the import was successful. Defaults to `true`.\n    ///   - isExportSuccessful: A Boolean value indicating whether the export was successful. Defaults to `true`.\n    ///   - isNetworkAvailable: A Boolean value indicating whether the network is available. Defaults to `true`.\n    ///   - iCloudAccountStatus: The iCloud account status. Defaults to `.available`.\n    ///   - errorText: An optional String containing the error message to be used if any operation was not successful.\n    ///\n    /// - Warning: Available only in DEBUG mode.\n    ///\n    /// This initializer creates a `SyncMonitor` instance with predefined states for setup, import, and export operations.\n    /// It also sets the network availability and iCloud account status. If an error text is provided, it creates an `NSError`\n    /// with that text as the domain.\n    ///\n    /// The initializer simulates a 15-second sync operation for all successful states.\n    ///\n    /// # Example Usage\n    ///\n    /// ```swift\n    /// let syncMonitor = SyncMonitor(isImportSuccessful: false, errorText: \"Cloud disrupted by weather net\")\n    /// ```\n    ///\n    /// - Note: This initializer cancels the monitoring task started by the designated initializer.\n    public convenience init(\n        isSetupSuccessful: Bool = true,\n        isImportSuccessful: Bool = true,\n        isExportSuccessful: Bool = true,\n        isNetworkAvailable: Bool = true,\n        iCloudAccountStatus: CKAccountStatus = .available,\n        errorText: String?\n    ) {\n        self.init()  // Call the designated initializer\n        \n        var error: Error? = nil\n        if let errorText = errorText {\n            error = NSError(domain: errorText, code: 0, userInfo: nil)\n        }\n        let startDate = Date(timeIntervalSinceNow: -15) // a 15 second sync\n        let endDate = Date()\n        \n        self.setupState = isSetupSuccessful\n        ? SyncState.succeeded(started: startDate, ended: endDate)\n        : .failed(started: startDate, ended: endDate, error: error)\n        self.importState = isImportSuccessful\n        ? .succeeded(started: startDate, ended: endDate)\n        : .failed(started: startDate, ended: endDate, error: error)\n        self.exportState = isExportSuccessful\n        ? .succeeded(started: startDate, ended: endDate)\n        : .failed(started: startDate, ended: endDate, error: error)\n        self.isNetworkAvailable = isNetworkAvailable\n        self.iCloudAccountStatus = iCloudAccountStatus\n        self.lastSyncError = error\n        \n        // Cancel the monitoring task started by the designated initializer\n        self.monitoringTask?.cancel()\n        self.monitoringTask = nil\n    }\n    #endif\n    \n    /// Initializes and starts the shared instance of `SyncMonitor`.\n    ///\n    /// Call this method as early as possible in your app's lifecycle to ensure that `SyncMonitor.default` accurately reflects the system's status when first accessed.\n    public func startMonitoring() {\n        _ = SyncMonitor.default\n    }\n    \n    // MARK: - Private methods -\n    \n    private func _startMonitoring() {\n        monitoringTask = Task {\n            try await withThrowingTaskGroup(of: Void.self) { group in\n                group.addTask { try await self.listenToSyncEvents() }\n                group.addTask { try await self.monitorNetworkChanges() }\n                group.addTask { await self.monitorICloudAccountStatus() }\n                \n                try await group.waitForAll()\n            }\n        }\n    }\n    \n    /// Listens to NSPersistentCloudKitContainer eventChangedNotification using async/await\n    private func listenToSyncEvents() async throws {\n        let notificationCenter = NotificationCenter.default\n        let syncEventStream = notificationCenter.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification)\n            .compactMap { notification -> SyncEvent? in\n                guard let cloudKitEvent = notification.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event else {\n                    return nil\n                }\n                return SyncEvent(from: cloudKitEvent)\n            }\n        \n        for await event in syncEventStream {\n            try Task.checkCancellation()\n            processSyncEvent(event)\n        }\n    }\n    \n    /// Monitors network changes asynchronously\n    private func monitorNetworkChanges() async throws {\n        for await path in networkPathUpdates() {\n            try Task.checkCancellation()\n            #if os(watchOS)\n            self.isNetworkAvailable = (path.availableInterfaces.count > 0)\n            #else\n            self.isNetworkAvailable = (path.status == .satisfied)\n            #endif\n        }\n    }\n    \n    private func monitorICloudAccountStatus() async {\n        // See https://stackoverflow.com/a/77072667 for .map() usage\n        let accountChangedStream = NotificationCenter.default.notifications(named: .CKAccountChanged).map { _ in () }\n        for await _ in accountChangedStream {\n            await updateICloudAccountStatus()\n        }\n    }\n    \n    private func updateICloudAccountStatus() async {\n        do {\n            let status = try await CKContainer.default().accountStatus()\n            self.iCloudAccountStatus = status\n            self.iCloudAccountStatusError = nil\n        } catch {\n            self.iCloudAccountStatusError = error\n        }\n    }\n    \n    private func networkPathUpdates() -> AsyncStream<NWPath> {\n        AsyncStream { continuation in\n            monitor.pathUpdateHandler = { path in\n                continuation.yield(path)\n            }\n            monitor.start(queue: DispatchQueue.global())\n            \n            continuation.onTermination = { @Sendable _ in\n                self.monitor.cancel()\n            }\n        }\n    }\n    \n    /// Set the appropriate State property (importState, exportState, setupState) based on the provided event\n    private func processSyncEvent(_ event: SyncEvent) {\n        // First, set the SyncState for the event\n        var state: SyncState = .notStarted\n        // NSPersistentCloudKitContainer sends a notification when an event starts, and another when it\n        // ends. If it has an endDate, it means the event finished.\n        if let startDate = event.startDate, event.endDate == nil {\n            state = .inProgress(started: startDate)\n        } else if let startDate = event.startDate, let endDate = event.endDate {\n            if event.succeeded {\n                state = .succeeded(started: startDate, ended: endDate)\n            } else {\n                state = .failed(started: startDate, ended: endDate, error: event.error)\n            }\n        }\n        \n        switch event.type {\n        case .setup:\n            setupState = state\n        case .import:\n            importState = state\n        case .export:\n            exportState = state\n        @unknown default:\n            assertionFailure(\"NSPersistentCloudKitContainer added a new event type.\")\n        }\n        \n        if case .failed(_, _, let error) = state, let error {\n            self.lastSyncError = error\n        }\n    }\n    \n    // MARK: - Helper types -\n    \n    /// Represents the overall status of iCloud sync.\n    public enum SyncSummaryStatus {\n        case noNetwork, accountNotAvailable, error, notSyncing, notStarted, inProgress, succeeded, unknown\n        \n        /// A SF Symbol name representing the current sync status.\n        public var symbolName: String {\n            switch self {\n            case .noNetwork:\n                return \"bolt.horizontal.icloud\"\n            case .accountNotAvailable:\n                return \"lock.icloud\"\n            case .error:\n                return \"exclamationmark.icloud\"\n            case .notSyncing:\n                return \"xmark.icloud\"\n            case .notStarted:\n                return \"bolt.horizontal.icloud\"\n            case .inProgress:\n                return \"arrow.clockwise.icloud\"\n            case .succeeded:\n                return \"icloud\"\n            case .unknown:\n                return \"icloud.slash\"\n            }\n        }\n        \n        /// A localized description of the current sync status.\n        public var description: String {\n            switch self {\n            case .noNetwork:\n                return String(localized: \"No network available\")\n            case .accountNotAvailable:\n                return String(localized: \"No iCloud account\")\n            case .error:\n                return String(localized: \"Error\")\n            case .notSyncing:\n                return String(localized: \"Not syncing to iCloud\")\n            case .notStarted:\n                return String(localized: \"Sync not started\")\n            case .inProgress:\n                return String(localized: \"Syncing...\")\n            case .succeeded:\n                return String(localized: \"Synced with iCloud\")\n            case .unknown:\n                return String(localized: \"Error\")\n            }\n        }\n        \n        /// A color suitable for displaying the status symbol.\n        public var symbolColor: Color {\n            switch self {\n            case .noNetwork, .accountNotAvailable, .notStarted, .inProgress:\n                return .gray\n            case .error, .notSyncing, .unknown:\n                return .red\n            case .succeeded:\n                return .green\n            }\n        }\n        \n        /// Indicates whether the current state represents a broken sync state.\n        public var isBroken: Bool {\n            switch self {\n            case .error, .notSyncing, .unknown:\n                return true\n            default:\n                return false\n            }\n        }\n        \n        @available(*, deprecated, renamed: \"isInProgress\")\n        public var inProgress: Bool { isInProgress }\n        \n        /// Indicates whether a sync operation is currently in progress.\n        public var isInProgress: Bool {\n            if case .inProgress = self {\n                return true\n            }\n            return false\n        }\n    }\n    \n    /// Represents the state of a CloudKit import, export, or setup event.\n    public enum SyncState: Equatable {\n        case notStarted\n        case inProgress(started: Date)\n        case succeeded(started: Date, ended: Date)\n        case failed(started: Date, ended: Date, error: Error?)\n        \n        /// Indicates whether the last sync of this type succeeded.\n        var didSucceed: Bool {\n            if case .succeeded = self { return true }\n            return false\n        }\n        \n        /// Indicates whether the last sync of this type failed.\n        var didFail: Bool {\n            if case .failed = self { return true }\n            return false\n        }\n        \n        /// The error returned if the event failed, or `nil` if the sync is incomplete or succeeded.\n        ///\n        /// - Important: This property reports all errors, including those caused by normal conditions like being offline. For more intelligent error reporting, see `SyncMonitor.importError` and `SyncMonitor.exportError`.\n        var error: Error? {\n            if case .failed(_, _, let error) = self, let error {\n                return error\n            }\n            return nil\n        }\n        \n        public static func == (lhs: SyncState, rhs: SyncState) -> Bool {\n            switch (lhs, rhs) {\n            case (.notStarted, .notStarted):\n                return true\n                \n            case (.inProgress(let lhsStarted), .inProgress(let rhsStarted)):\n                return lhsStarted == rhsStarted\n                \n            case (.succeeded(let lhsStarted, let lhsEnded), .succeeded(let rhsStarted, let rhsEnded)):\n                return lhsStarted == rhsStarted && lhsEnded == rhsEnded\n                \n            case (.failed(let lhsStarted, let lhsEnded, let lhsError), .failed(let rhsStarted, let rhsEnded, let rhsError)):\n                \n                let datesEqual = lhsStarted == rhsStarted && lhsEnded == rhsEnded\n                \n                // Since Error doesn't conform to Equatable, we'll compare their localized descriptions\n                let errorsEqual: Bool\n                switch (lhsError, rhsError) {\n                case (nil, nil):\n                    errorsEqual = true\n                case (let lhsErr?, let rhsErr?):\n                    errorsEqual = lhsErr.localizedDescription == rhsErr.localizedDescription\n                default:\n                    errorsEqual = false\n                }\n                \n                return datesEqual && errorsEqual\n                \n            default:\n                return false\n            }\n        }\n    }\n    \n    /// A sync event containing the values from NSPersistentCloudKitContainer.Event that we track\n    internal struct SyncEvent {\n        var type: NSPersistentCloudKitContainer.EventType\n        var startDate: Date?\n        var endDate: Date?\n        var succeeded: Bool\n        var error: Error?\n        \n        /// Creates a SyncEvent from an NSPersistentCloudKitContainer Event\n        init(from cloudKitEvent: NSPersistentCloudKitContainer.Event) {\n            self.type = cloudKitEvent.type\n            self.startDate = cloudKitEvent.startDate\n            self.endDate = cloudKitEvent.endDate\n            self.succeeded = cloudKitEvent.succeeded\n            self.error = cloudKitEvent.error\n        }\n    }\n}\n"
  },
  {
    "path": "Tests/CloudKitSyncMonitorTests/SyncMonitorTests.swift",
    "content": "//\n//  SyncMonitorTests.swift\n//  \n//\n//  Created by Grant Grueninger on 9/23/20.\n//\n\nimport Foundation\n\nimport XCTest\nimport CoreData\n@testable import CloudKitSyncMonitor\n\n@available(iOS 15.0, macCatalyst 14.0, OSX 11, tvOS 15.0, *)\nfinal class SyncMonitorTests: XCTestCase {\n    func testCanDetectImportError() {\n        // Given an active network connection\n        let syncStatus: SyncMonitor = SyncMonitor(networkAvailable: true, listen: false)\n\n        // When NSPersistentCloudKitContainer reports an unsuccessful import\n        let errorText = \"I don't like clouds\"\n        let error = NSError(domain: errorText, code: 0, userInfo: nil)\n        let event = SyncMonitor.SyncEvent(type: .import, startDate: Date(), endDate: Date(), succeeded: false,\n                                          error: error)\n        syncStatus.setProperties(from: event)\n\n        // Then importError's description is \"I don't like clouds\"\n        XCTAssertEqual(syncStatus.importError?.localizedDescription, error.localizedDescription)\n\n        // and importState failed\n        if case .failed = syncStatus.importState {\n            XCTAssert(true)\n        } else {\n            XCTAssert(false, \"importState should be .failed\")\n        }\n    }\n\n    func testCanDetectExportError() {\n        // Given an active network connection\n        let syncStatus = SyncMonitor(networkAvailable: true, listen: false)\n\n        // When NSPersistentCloudKitContainer reports an unsuccessful import\n        let errorText = \"I don't like clouds\"\n        let error = NSError(domain: errorText, code: 0, userInfo: nil)\n        let event = SyncMonitor.SyncEvent(type: .export, startDate: Date(), endDate: Date(), succeeded: false,\n                                          error: error)\n        syncStatus.setProperties(from: event)\n\n        // Then exportError's description is \"I don't like clouds\"\n        XCTAssertEqual(syncStatus.exportError?.localizedDescription, error.localizedDescription)\n\n        // and exportState failed\n        if case .failed = syncStatus.exportState {\n            XCTAssert(true)\n        } else {\n            XCTAssert(false, \"exportState should be .failed\")\n        }\n    }\n\n    func testCanDetectImportSuccess() {\n        // Given an active network connection\n        let syncStatus: SyncMonitor = SyncMonitor(networkAvailable: true, listen: false)\n\n        // When NSPersistentCloudKitContainer reports a successful import\n        let event = SyncMonitor.SyncEvent(type: .import, startDate: Date(), endDate: Date(), succeeded: true,\n                                          error: nil)\n        syncStatus.setProperties(from: event)\n\n        // Then importError is nil\n        XCTAssert(syncStatus.importError == nil)\n\n        // and importState is .succeeded\n        if case .succeeded = syncStatus.importState {\n            XCTAssert(true)\n        } else {\n            XCTAssert(false, \"importState should be .succeeded\")\n        }\n    }\n\n    func testCanDetectExportSuccess() {\n        // Given an active network connection\n        let syncStatus: SyncMonitor = SyncMonitor(networkAvailable: true, listen: false)\n\n        // When NSPersistentCloudKitContainer reports a successful export\n        let event = SyncMonitor.SyncEvent(type: .export, startDate: Date(), endDate: Date(), succeeded: true,\n                                          error: nil)\n        syncStatus.setProperties(from: event)\n\n        // Then exportError is nil\n        XCTAssert(syncStatus.exportError == nil)\n\n        // and exportState is .succeeded\n        if case .succeeded = syncStatus.exportState {\n            XCTAssert(true)\n        } else {\n            XCTAssert(false, \"exportState should be .succeeded\")\n        }\n    }\n\n    func testSetsStatusToInProgressWhenEventHasNoEndDate() {\n        // Given an active network connection\n        let syncStatus: SyncMonitor = SyncMonitor(networkAvailable: true, listen: false)\n\n        // When NSPersistentCloudKitContainer reports an event with a start date but no end date\n        let event = SyncMonitor.SyncEvent(type: .export, startDate: Date(), endDate: nil, succeeded: false,\n                                          error: nil)\n        syncStatus.setProperties(from: event)\n\n        // Then exportError is nil\n        XCTAssert(syncStatus.exportError == nil)\n\n        // and exportState is .inProgress\n        if case .inProgress = syncStatus.exportState {\n            XCTAssert(true)\n        } else {\n            XCTAssert(false, \"exportState should be .inProgress\")\n        }\n    }\n\n    func testSetsStatusToNotStartedOnStartup() {\n        // Given an active network connection\n        let syncStatus: SyncMonitor = SyncMonitor(networkAvailable: true, listen: false)\n\n        // When we check status before an event has been reported\n        let status = syncStatus.importState\n\n        // Then the status is \".notStarted\"\n        if case .notStarted = status {\n            XCTAssert(true)\n        } else {\n            XCTAssert(false, \"importState should be .notStarted\")\n        }\n    }\n\n    static var allTests = [\n        (\"testCanDetectImportError\", testCanDetectImportError),\n        (\"testCanDetectExportError\", testCanDetectExportError),\n        (\"testCanDetectImportSuccess\", testCanDetectImportSuccess),\n        (\"testCanDetectExportSuccess\", testCanDetectExportSuccess),\n        (\"testSetsStatusToInProgressWhenEventHasNoEndDate\", testSetsStatusToInProgressWhenEventHasNoEndDate),\n        (\"testSetsStatusToNotStartedOnStartup\", testSetsStatusToNotStartedOnStartup),\n    ]\n}\n"
  },
  {
    "path": "Tests/CloudKitSyncMonitorTests/XCTestManifests.swift",
    "content": "import XCTest\n\n#if !canImport(ObjectiveC)\npublic func allTests() -> [XCTestCaseEntry] {\n    return [\n        testCase(SyncMonitorTests.allTests),\n    ]\n}\n#endif\n"
  },
  {
    "path": "Tests/LinuxMain.swift",
    "content": "import XCTest\n\nimport CloudKitSyncMonitorTests\n\nvar tests = [XCTestCaseEntry]()\ntests += SyncStatusTests.allTests()\ntests += SyncMonitorTests.allTests()\nXCTMain(tests)\n"
  }
]