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