← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit 8a835f1a98b20bca588a168a48c448546dc3c4d8
parent 1a49007c0942dd19810380d0c8c8f7059855628f
Author: Michael Camilleri <[email protected]>
Date:   Fri, 27 Feb 2026 06:49:29 +0900

Improve handling of transient CloudKit errors

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat

MListless.xcodeproj/project.pbxproj | 4++++
MListless/Sync/CloudKitErrorClassifier.swift | 4++++
MListless/Sync/CloudKitSyncMonitor.swift | 12++++++++++++
ATests/Unit/CloudKitErrorClassifierTests.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

4 files changed, 164 insertions(+), 0 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -49,6 +49,7 @@
 		BA6953E0EFE6F8255F05A3FD /* AccentColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17DD7EDA74DAAFA27C84CA08 /* AccentColor.swift */; };
 		C169823665158AA347A63990 /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51B8129962E5CC78ECDDC2B /* TaskListView.swift */; };
 		C1FE091454864C4BBBBEB077 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
+		CA439FD953EA59A9664E0D74 /* CloudKitErrorClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */; };
 		CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; };
 		CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B4668483D05A6ECA142B89 /* TaskListView+Logic.swift */; };
 		CC99A96BBC089C423F582E4F /* TaskListViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C018476BD91B73870244B9 /* TaskListViewProtocol.swift */; };
@@ -94,6 +95,7 @@
 		466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; };
 		4C79ABB39A40D3E1828716C7 /* AppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommands.swift; sourceTree = "<group>"; };
 		4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
+		51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitErrorClassifierTests.swift; sourceTree = "<group>"; };
 		567DBAC2A39FA2760D006AAB /* PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToClear.swift; sourceTree = "<group>"; };
 		5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToClear.swift"; sourceTree = "<group>"; };
 		5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -318,6 +320,7 @@
 			isa = PBXGroup;
 			children = (
 				C9B14DC786A336008AAB78EE /* .gitkeep */,
+				51C3B9FF63645B35E09CF1B1 /* CloudKitErrorClassifierTests.swift */,
 				5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */,
 				9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */,
 				2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */,
@@ -570,6 +573,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				CA439FD953EA59A9664E0D74 /* CloudKitErrorClassifierTests.swift in Sources */,
 				F50E8B8F73F64D8E641DC74C /* TaskStoreCompletionTests.swift in Sources */,
 				99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */,
 				269B93D5543770B464DFB37A /* TaskStoreOrderingTests.swift in Sources */,

diff --git a/Listless/Sync/CloudKitErrorClassifier.swift b/Listless/Sync/CloudKitErrorClassifier.swift

@@ -14,6 +14,7 @@ struct SyncAlertItem: Identifiable {
 
 enum SyncIssue {
     case transient(message: String)
+    case deferred(message: String)
     case alert(SyncAlertItem)
 }
 
@@ -78,6 +79,9 @@ enum CloudKitErrorClassifier {
                     action: .openSettings
                 ))
 
+        case .accountTemporarilyUnavailable, .zoneNotFound, .userDeletedZone:
+            return .deferred(message: "Saved locally. iCloud sync will retry automatically.")
+
         default:
             return .alert(
                 SyncAlertItem(

diff --git a/Listless/Sync/CloudKitSyncMonitor.swift b/Listless/Sync/CloudKitSyncMonitor.swift

@@ -7,9 +7,11 @@ final class CloudKitSyncMonitor: ObservableObject {
     @Published var actionableAlert: SyncAlertItem?
 
     private var monitoringTask: Task<Void, Never>?
+    private var deferredTask: Task<Void, Never>?
 
     deinit {
         monitoringTask?.cancel()
+        deferredTask?.cancel()
     }
 
     func startMonitoring(container: NSPersistentCloudKitContainer) {
@@ -33,6 +35,8 @@ final class CloudKitSyncMonitor: ObservableObject {
                 if let error = event.error {
                     self.handle(issue: CloudKitErrorClassifier.classify(error))
                 } else if self.isSuccessfulSyncCompletion(event) {
+                    self.deferredTask?.cancel()
+                    self.deferredTask = nil
                     self.transientErrorMessage = nil
                 }
             }
@@ -52,6 +56,14 @@ final class CloudKitSyncMonitor: ObservableObject {
         case .transient(let message):
             showTransient(message)
 
+        case .deferred(let message):
+            guard deferredTask == nil else { return }
+            deferredTask = Task {
+                try? await Task.sleep(for: .seconds(60))
+                guard !Task.isCancelled else { return }
+                showTransient(message)
+            }
+
         case .alert(let alert):
             actionableAlert = alert
         }

diff --git a/Tests/Unit/CloudKitErrorClassifierTests.swift b/Tests/Unit/CloudKitErrorClassifierTests.swift

@@ -0,0 +1,144 @@
+import CloudKit
+import Foundation
+import Testing
+
+@testable import Listless_iOS
+
+@Suite("CloudKitErrorClassifier")
+struct CloudKitErrorClassifierTests {
+
+    // MARK: - Transient Errors
+
+    @Test(
+        "Network and server errors are transient",
+        arguments: [
+            CKError.Code.networkUnavailable,
+            CKError.Code.networkFailure,
+            CKError.Code.serviceUnavailable,
+            CKError.Code.requestRateLimited,
+            CKError.Code.zoneBusy,
+            CKError.Code.serverResponseLost,
+            CKError.Code.operationCancelled,
+        ]
+    )
+    func transientErrors(code: CKError.Code) {
+        let error = CKError(code)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .transient(let message) = issue else {
+            Issue.record("Expected .transient, got \(issue)")
+            return
+        }
+        #expect(message.contains("retry"))
+    }
+
+    // MARK: - Deferred Errors
+
+    @Test(
+        "First-launch errors are deferred",
+        arguments: [
+            CKError.Code.accountTemporarilyUnavailable,
+            CKError.Code.zoneNotFound,
+            CKError.Code.userDeletedZone,
+        ]
+    )
+    func deferredErrors(code: CKError.Code) {
+        let error = CKError(code)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .deferred(let message) = issue else {
+            Issue.record("Expected .deferred, got \(issue)")
+            return
+        }
+        #expect(message.contains("retry"))
+    }
+
+    // MARK: - Actionable Alerts
+
+    @Test("Not authenticated requires sign-in")
+    func notAuthenticated() {
+        let error = CKError(.notAuthenticated)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .alert(let alert) = issue else {
+            Issue.record("Expected .alert, got \(issue)")
+            return
+        }
+        #expect(alert.title.contains("Sign-In"))
+        #expect(alert.action == .openSettings)
+    }
+
+    @Test("Quota exceeded shows storage full")
+    func quotaExceeded() {
+        let error = CKError(.quotaExceeded)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .alert(let alert) = issue else {
+            Issue.record("Expected .alert, got \(issue)")
+            return
+        }
+        #expect(alert.title.contains("Storage Full"))
+        #expect(alert.action == .openSettings)
+    }
+
+    @Test(
+        "Permission errors show unavailable",
+        arguments: [
+            CKError.Code.permissionFailure,
+            CKError.Code.badContainer,
+            CKError.Code.missingEntitlement,
+        ]
+    )
+    func permissionErrors(code: CKError.Code) {
+        let error = CKError(code)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .alert(let alert) = issue else {
+            Issue.record("Expected .alert, got \(issue)")
+            return
+        }
+        #expect(alert.title.contains("Unavailable"))
+        #expect(alert.action == .openSettings)
+    }
+
+    // MARK: - Default / Unknown CKError
+
+    @Test("Unknown CKError code shows sync error alert")
+    func unknownCKError() {
+        let error = CKError(.internalError)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .alert(let alert) = issue else {
+            Issue.record("Expected .alert, got \(issue)")
+            return
+        }
+        #expect(alert.title == "Sync Error")
+        #expect(alert.action == nil)
+    }
+
+    // MARK: - Non-CloudKit Errors
+
+    @Test("Core Data error shows save failure alert")
+    func coreDataError() {
+        let error = NSError(domain: NSCocoaErrorDomain, code: 1570, userInfo: nil)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .alert(let alert) = issue else {
+            Issue.record("Expected .alert, got \(issue)")
+            return
+        }
+        #expect(alert.title == "Unable to Save Changes")
+    }
+
+    @Test("Unknown domain error shows generic sync error")
+    func unknownDomainError() {
+        let error = NSError(domain: "com.example.unknown", code: 42, userInfo: nil)
+        let issue = CloudKitErrorClassifier.classify(error)
+
+        guard case .alert(let alert) = issue else {
+            Issue.record("Expected .alert, got \(issue)")
+            return
+        }
+        #expect(alert.title == "Sync Error")
+    }
+}