listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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") + } +}