Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- The welcome screen groups the ways to add an existing connection into one **Add from Existing** menu, next to **Create Connection**. Import from URL now has a home in that menu and in the File menu, where it had none. **Try Sample Database** moved to the connection list, which already offers it when the list is empty.
- A failed iCloud sync on iPhone and iPad now says what went wrong instead of showing a raw error. Out of storage, no network, and a rejected change each get their own message, the same ones the Mac already showed. The Mac and the mobile app now run the same sync engine, so a sync fix on one reaches the other. (#1990)

### Fixed

Expand Down
18 changes: 2 additions & 16 deletions Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,11 @@ public enum SyncRecordMapper {
// MARK: - Record Name Helpers

public static func recordID(type: SyncRecordType, id: String, in zone: CKRecordZone.ID) -> CKRecord.ID {
let recordName: String
switch type {
case .connection: recordName = "Connection_\(id)"
case .group: recordName = "Group_\(id)"
case .tag: recordName = "Tag_\(id)"
}
return CKRecord.ID(recordName: recordName, zoneID: zone)
CKRecord.ID(recordName: type.recordName(for: id), zoneID: zone)
}

public static func parse(recordName: String) -> (type: SyncRecordType, id: String)? {
let prefixes: [(SyncRecordType, String)] = [
(.connection, "Connection_"),
(.group, "Group_"),
(.tag, "Tag_")
]
for (type, prefix) in prefixes where recordName.hasPrefix(prefix) {
return (type, String(recordName.dropFirst(prefix.count)))
}
return nil
SyncRecordType.parse(recordName: recordName)
}

// MARK: - Connection -> CKRecord
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CloudKit
import Foundation
import os
import Security

public struct PullResult: Sendable {
public let changedRecords: [CKRecord]
Expand All @@ -17,34 +18,56 @@ public struct PullResult: Sendable {
public actor CloudKitSyncEngine {
private static let logger = Logger(subsystem: "com.TablePro", category: "CloudKitSyncEngine")

private let container: CKContainer
private let database: CKDatabase
private let container: CKContainer?
private let database: CKDatabase?
private let zoneID: CKRecordZone.ID

public static let zoneName = "TableProSync"
public static let defaultContainerID = "iCloud.com.TablePro"

/// CloudKit allows at most 400 items (saves + deletions) per modify operation
private static let maxBatchSize = 400
private static let maxRetries = 3

public static func hasICloudEntitlement() -> Bool {
#if os(macOS)
guard let task = SecTaskCreateFromSelf(nil) else { return false }
return SecTaskCopyValueForEntitlement(task, "com.apple.developer.icloud-services" as CFString, nil) != nil
#else
return true
#endif
}

public init(containerIdentifier: String = defaultContainerID) {
self.container = CKContainer(identifier: containerIdentifier)
self.database = container.privateCloudDatabase
self.zoneID = CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)
if Self.hasICloudEntitlement() {
let container = CKContainer(identifier: containerIdentifier)
self.container = container
database = container.privateCloudDatabase
} else {
container = nil
database = nil
Self.logger.warning("iCloud entitlement missing: CloudKit sync disabled")
}
zoneID = CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)
}

public var currentZoneID: CKRecordZone.ID { zoneID }

// MARK: - Account Status

public func accountStatus() async throws -> CKAccountStatus {
try await container.accountStatus()
guard let container else { throw SyncError.accountUnavailable }
return try await container.accountStatus()
}

public func currentAccountId() async throws -> String? {
guard let container else { throw SyncError.accountUnavailable }
return try await container.userRecordID().recordName
}

// MARK: - Zone Management

public func ensureZoneExists() async throws {
guard let database else { throw SyncError.accountUnavailable }
let zone = CKRecordZone(zoneID: zoneID)
_ = try await database.save(zone)
Self.logger.trace("Created or confirmed sync zone: \(Self.zoneName)")
Expand Down Expand Up @@ -85,7 +108,8 @@ public actor CloudKitSyncEngine {
}

private func pushBatch(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome {
try await withRetry {
guard let database else { throw SyncError.accountUnavailable }
return try await withRetry {
let operation = CKModifyRecordsOperation(
recordsToSave: records,
recordIDsToDelete: deletions
Expand Down Expand Up @@ -128,7 +152,7 @@ public actor CloudKitSyncEngine {
}
}

self.database.add(operation)
database.add(operation)
}
}
}
Expand Down Expand Up @@ -165,6 +189,7 @@ public actor CloudKitSyncEngine {
}

private func performPull(since token: CKServerChangeToken?) async throws -> PullPage {
guard let database else { throw SyncError.accountUnavailable }
let configuration = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
configuration.previousServerChangeToken = token

Expand Down Expand Up @@ -200,8 +225,6 @@ public actor CloudKitSyncEngine {
moreComing = hasMore
case .failure(let error):
Self.logger.warning("Zone fetch result error: \(error.localizedDescription)")
// Zone-level failure with records collected so far is acceptable:
// newToken stays nil, forcing a full re-fetch on next sync cycle.
}
}

Expand All @@ -217,12 +240,11 @@ public actor CloudKitSyncEngine {
moreComing: moreComing
))
case .failure(let error):
// Map CKError.changeTokenExpired to SyncError.tokenExpired
if let ckError = error as? CKError, ckError.code == .changeTokenExpired {
continuation.resume(throwing: SyncError.tokenExpired)
} else {
guard let ckError = error as? CKError, ckError.code == .changeTokenExpired else {
continuation.resume(throwing: error)
return
}
continuation.resume(throwing: SyncError.tokenExpired)
}
}

Expand Down Expand Up @@ -250,7 +272,7 @@ public actor CloudKitSyncEngine {
}
}

throw lastError ?? SyncError.unknownError("Max retries exceeded")
throw lastError ?? SyncError.unknown("Max retries exceeded")
}

private func isTransientError(_ error: CKError) -> Bool {
Expand Down
73 changes: 56 additions & 17 deletions Packages/TableProCore/Sources/TableProSyncTransport/SyncError.swift
Original file line number Diff line number Diff line change
@@ -1,30 +1,69 @@
import CloudKit
import Foundation

public enum SyncError: Error, LocalizedError, Equatable, Sendable {
case noAccount
case networkUnavailable
case zoneCreationFailed(String)
case pushFailed(String)
case pullFailed(String)
case accountUnavailable
case quotaExceeded
case zoneNotFound
case serverError(String)
case conflictDetected
case encodingFailed(String)
case pushRejected(count: Int, detail: String)
case tokenExpired
case unknownError(String)
case unknown(String)

public var errorDescription: String? {
switch self {
case .noAccount:
return String(localized: "No iCloud account available")
case .networkUnavailable:
return String(localized: "Network is unavailable")
case .zoneCreationFailed(let detail):
return String(format: String(localized: "Failed to create sync zone: %@"), detail)
case .pushFailed(let detail):
return String(format: String(localized: "Failed to push changes: %@"), detail)
case .pullFailed(let detail):
return String(format: String(localized: "Failed to pull changes: %@"), detail)
return String(localized: "Network is unavailable. Changes will sync when connectivity is restored.")
case .accountUnavailable:
return String(localized: "iCloud account is not available. Sign in to iCloud in System Settings.")
case .quotaExceeded:
return String(localized: "iCloud storage is full. Free up space or reduce the history sync limit.")
case .zoneNotFound:
return String(localized: "Sync zone not found. A full sync will be performed.")
case .serverError(let message):
return String(format: String(localized: "iCloud server error: %@"), message)
case .conflictDetected:
return String(localized: "A sync conflict was detected and needs to be resolved.")
case .encodingFailed(let detail):
return String(format: String(localized: "Failed to encode sync data: %@"), detail)
case .pushRejected(let count, let detail):
return String(
format: String(localized: "iCloud rejected %d change(s). They stay on this device and will retry: %@"),
count,
detail
)
case .tokenExpired:
return String(localized: "Sync token expired, full sync required")
case .unknownError(let detail):
return String(format: String(localized: "Sync error: %@"), detail)
return String(localized: "Sync token expired. A full sync will be performed.")
case .unknown(let message):
return String(format: String(localized: "An unknown sync error occurred: %@"), message)
}
}

public static func from(_ error: Error) -> SyncError {
if let syncError = error as? SyncError {
return syncError
}

if let ckError = error as? CKError {
switch ckError.code {
case .networkUnavailable, .networkFailure:
return .networkUnavailable
case .notAuthenticated:
return .accountUnavailable
case .quotaExceeded:
return .quotaExceeded
case .zoneNotFound:
return .zoneNotFound
case .changeTokenExpired:
return .tokenExpired
default:
return .serverError(ckError.localizedDescription)
}
}

return .unknown(error.localizedDescription)
}
}
Loading
Loading