├── Bad Habits ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── IMG_6010.png │ │ ├── IMG_6010 1.png │ │ ├── IMG_6010 2.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Helpers │ ├── Design │ │ ├── Design.swift │ │ ├── Design+CornerRadius.swift │ │ └── Design+Spacing.swift │ └── Sheet.swift ├── Bad_Habits.xcdatamodeld │ ├── .xccurrentversion │ └── Bad_Habits.xcdatamodel │ │ └── contents ├── Bad Habits.entitlements ├── Extensions │ ├── CoreData │ │ ├── Problem+Extension.swift │ │ ├── BadHabit+Extension.swift │ │ └── Oopsie+Extension.swift │ └── Date+Extension.swift ├── Persistence.swift ├── Magic │ └── MYRecordConvertible │ │ ├── Problem+MYRecordConvertible.swift │ │ ├── BadHabit+MYRecordConvertible.swift │ │ └── Oopsie+MYRecordConvertible.swift ├── Views │ ├── Create │ │ ├── CreateView.swift │ │ ├── CreateView+ProblemView.swift │ │ └── CreateView+BadHabitView.swift │ ├── Update │ │ ├── UpdateView.swift │ │ ├── UpdateView+ProblemView.swift │ │ └── UpdateView+BadHabitView.swift │ └── Home │ │ ├── ShareView.swift │ │ ├── HomeView.swift │ │ └── HomeView+ProblemSection.swift ├── Bad_HabitsApp.swift └── AppState.swift ├── Frameworks └── MYCloudKit │ ├── Sources │ └── MYCloudKit │ │ ├── MYCloudKit.swift │ │ ├── Extensions │ │ ├── CKDatabase+Extension.swift │ │ ├── CKServerChangeToken+Extension.swift │ │ ├── CKRecord+Extension.swift │ │ └── UserDefault+Extension.swift │ │ ├── MYSyncEngine │ │ ├── MYSyncEngine+Logger.swift │ │ ├── MYCloudEngine+Subscriptions.swift │ │ ├── MYCloudEngine+Share.swift │ │ ├── MYSyncDelegate.swift │ │ ├── MYSyncEngine+Cache.swift │ │ ├── MYSyncEngine.swift │ │ ├── MYCloudEngine+CRUD.swift │ │ ├── MYCloudEngine+Transaction.swift │ │ ├── MYCloudEngine+Fetch.swift │ │ └── MYCloudEngine+Sync.swift │ │ └── Helpers │ │ └── MYRecordConvertible.swift │ ├── .gitignore │ └── Package.swift ├── README.md ├── Bad Habits.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcuserdata │ │ └── mufasa.xcuserdatad │ │ │ └── IDEFindNavigatorScopes.plist │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── mufasa.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist └── project.pbxproj └── Bad-Habits-Info.plist /Bad Habits/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYCloudKit.swift: -------------------------------------------------------------------------------- 1 | // Crafted by @mufasayc — find me on X, Instagram, or pretty much anywhere. Same handle, same vibes 😄 2 | -------------------------------------------------------------------------------- /Bad Habits/Assets.xcassets/AppIcon.appiconset/IMG_6010.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mufasaYC/Bad-Habits/HEAD/Bad Habits/Assets.xcassets/AppIcon.appiconset/IMG_6010.png -------------------------------------------------------------------------------- /Bad Habits/Assets.xcassets/AppIcon.appiconset/IMG_6010 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mufasaYC/Bad-Habits/HEAD/Bad Habits/Assets.xcassets/AppIcon.appiconset/IMG_6010 1.png -------------------------------------------------------------------------------- /Bad Habits/Assets.xcassets/AppIcon.appiconset/IMG_6010 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mufasaYC/Bad-Habits/HEAD/Bad Habits/Assets.xcassets/AppIcon.appiconset/IMG_6010 2.png -------------------------------------------------------------------------------- /Bad Habits/Helpers/Design/Design.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Design.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Design { } 11 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bad-Habits 2 | This was the app I made for my talk at iOS Conference Singapore 3 | 4 | All fatalErrors and force unwraps are intentional and purely for the sake of the talk and fun. 5 | 6 | Do not try at home. -------------------------------------------------------------------------------- /Bad Habits.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Bad Habits.xcodeproj/xcuserdata/mufasa.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /Bad Habits.xcodeproj/project.xcworkspace/xcuserdata/mufasa.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Bad Habits/Bad_Habits.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | Bad_Habits.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Bad Habits/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "systemRedColor" 7 | }, 8 | "idiom" : "universal" 9 | } 10 | ], 11 | "info" : { 12 | "author" : "xcode", 13 | "version" : 1 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Bad-Habits-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CKSharingSupported 6 | 7 | UIBackgroundModes 8 | 9 | remote-notification 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Bad Habits/Helpers/Design/Design+CornerRadius.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Design+CornerRadius.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias CornerRadius = Design.CornerRadius 11 | 12 | extension Design { 13 | struct CornerRadius { 14 | static let standard: CGFloat = 16 15 | static let medium: CGFloat = 8 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Bad Habits/Helpers/Design/Design+Spacing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Design+Spacing.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias Spacing = Design.Spacing 11 | 12 | extension Design { 13 | struct Spacing { 14 | static let large: CGFloat = 24 15 | static let standard: CGFloat = 16 16 | static let medium: CGFloat = 8 17 | static let small: CGFloat = 4 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Bad Habits.xcodeproj/xcuserdata/mufasa.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Bad Habits.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Bad Habits.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "deb255195fc9d5ae7cd690c70f34d10b78a7ed4c9cc0726d6cde2fc961f5467f", 3 | "pins" : [ 4 | { 5 | "identity" : "mycloudkit", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/mufasayc/MYCloudKit.git", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "88ccd236c1d2f532f72c9182b2d2d58d592a842f" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Bad Habits/Bad Habits.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.com.myc.test 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Bad Habits/Extensions/CoreData/Problem+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Problem+Extension.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import CoreData 9 | 10 | extension Problem { 11 | class func fetchRequest(with id: String) -> NSFetchRequest { 12 | let request = Problem.fetchRequest() 13 | if let uuid = UUID(uuidString: id) { 14 | request.predicate = .init(format: "id == %@", uuid as CVarArg) 15 | } 16 | request.fetchLimit = 1 17 | return request 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/Extensions/CKDatabase+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 07/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | extension CKDatabase.Scope { 8 | 9 | // for logging purposes 10 | var name: String { 11 | switch self { 12 | case .private: 13 | return "private" 14 | case .shared: 15 | return "shared" 16 | case .public: 17 | return "public" 18 | @unknown default: 19 | return "Unknown" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "MYCloudKit", 8 | platforms: [ 9 | .iOS(.v15), 10 | .watchOS(.v8), 11 | .macCatalyst(.v15), 12 | .macOS(.v12), 13 | .tvOS(.v15), 14 | .visionOS(.v1) 15 | ], 16 | products: [ 17 | .library( 18 | name: "MYCloudKit", 19 | targets: ["MYCloudKit"] 20 | ), 21 | ], 22 | targets: [ 23 | .target( 24 | name: "MYCloudKit" 25 | ) 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Bad Habits/Helpers/Sheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sheet.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum Sheet: Identifiable { 11 | case create(CreateView.Item) 12 | case update(UpdateView.Item) 13 | 14 | var id: String { 15 | switch self { 16 | case .create(let item): 17 | return item.id 18 | case .update(let item): 19 | return item.id 20 | } 21 | } 22 | 23 | @ViewBuilder 24 | var view: some View { 25 | switch self { 26 | case .create(let item): 27 | CreateView(item: item) 28 | case .update(let item): 29 | UpdateView(item: item) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Bad Habits/Persistence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Persistence.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 14/01/25. 6 | // 7 | 8 | import CoreData 9 | 10 | struct PersistenceController { 11 | static let shared = PersistenceController() 12 | 13 | private let container: NSPersistentContainer 14 | let viewContext: NSManagedObjectContext 15 | 16 | init() { 17 | container = NSPersistentContainer(name: "Bad_Habits") 18 | container.loadPersistentStores(completionHandler: { (storeDescription, error) in 19 | if let error = error as NSError? { 20 | fatalError("Unresolved error \(error), \(error.userInfo)") 21 | } 22 | }) 23 | container.viewContext.automaticallyMergesChangesFromParent = true 24 | viewContext = container.viewContext 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/Extensions/CKServerChangeToken+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 06/05/25. 3 | // 4 | 5 | import CloudKit.CKServerChangeToken 6 | 7 | extension CKServerChangeToken { 8 | 9 | /// Converts the server change token into a Base64-encoded string for storage (e.g., in UserDefaults). 10 | /// 11 | /// - Returns: A Base64 string representation of the token, or `nil` if encoding fails. 12 | func asString() -> String? { 13 | do { 14 | let data = try NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: false) 15 | return data.base64EncodedString() 16 | } catch { 17 | assertionFailure("❌ Failed to encode CKServerChangeToken to string: \(error.localizedDescription)") 18 | return nil 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Bad Habits/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "IMG_6010.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "appearances" : [ 11 | { 12 | "appearance" : "luminosity", 13 | "value" : "dark" 14 | } 15 | ], 16 | "filename" : "IMG_6010 1.png", 17 | "idiom" : "universal", 18 | "platform" : "ios", 19 | "size" : "1024x1024" 20 | }, 21 | { 22 | "appearances" : [ 23 | { 24 | "appearance" : "luminosity", 25 | "value" : "tinted" 26 | } 27 | ], 28 | "filename" : "IMG_6010 2.png", 29 | "idiom" : "universal", 30 | "platform" : "ios", 31 | "size" : "1024x1024" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Bad Habits/Extensions/CoreData/BadHabit+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BadHabit+Extension.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import CoreData 9 | 10 | extension BadHabit { 11 | class func fetchRequest(for problem: Problem) -> NSFetchRequest { 12 | let request = BadHabit.fetchRequest() 13 | request.predicate = .init(format: "problem == %@", problem) 14 | request.sortDescriptors = [.init(keyPath: \BadHabit.title, ascending: true)] 15 | return request 16 | } 17 | } 18 | 19 | extension BadHabit { 20 | class func fetchRequest(with id: String) -> NSFetchRequest { 21 | let request = BadHabit.fetchRequest() 22 | if let uuid = UUID(uuidString: id) { 23 | request.predicate = .init(format: "id == %@", uuid as CVarArg) 24 | } 25 | request.fetchLimit = 1 26 | return request 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Bad Habits/Extensions/Date+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Extension.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | func startOfMonth() -> Date? { 12 | let calendar = Calendar.current 13 | return calendar.date(from: calendar.dateComponents([.year, .month], from: self)) 14 | } 15 | 16 | func endOfMonth() -> Date? { 17 | let calendar = Calendar.current 18 | guard let startOfNextMonth = calendar.date(byAdding: .month, value: 1, to: self.startOfMonth()!) else { 19 | return nil 20 | } 21 | return calendar.date(byAdding: .day, value: -1, to: startOfNextMonth) 22 | } 23 | } 24 | 25 | extension Date { 26 | func monthTitle() -> String { 27 | let formatter = DateFormatter() 28 | formatter.dateFormat = "MMM" 29 | return formatter.string(from: self) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Bad Habits/Magic/MYRecordConvertible/Problem+MYRecordConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Problem+CKRecordConvertible.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 16/01/25. 6 | // 7 | 8 | import CloudKit.CKRecord 9 | import MYCloudKit 10 | 11 | extension Problem: MYRecordConvertible { 12 | 13 | public var myRecordID: String { 14 | self.id?.uuidString ?? UUID().uuidString 15 | } 16 | 17 | public var myRecordType: String { 18 | "Problem" 19 | } 20 | 21 | public var myRootGroupID: String? { 22 | switch AppState.shared.shareType { 23 | case .recordWithMYZone: 24 | return nil 25 | case .zone, .recordWithCustomZone: 26 | return id?.uuidString 27 | } 28 | } 29 | 30 | public var myParentID: String? { 31 | nil 32 | } 33 | 34 | public var myProperties: [String: MYRecordValue] { 35 | [ 36 | "title": .string(title), 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Bad Habits/Views/Create/CreateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CreateView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CreateView: View { 11 | enum Item: Identifiable { 12 | case problem 13 | case badHabit(problem: Problem) 14 | 15 | var id: String { 16 | switch self { 17 | case .problem: 18 | return "problem" 19 | case .badHabit(let problem): 20 | return problem.id?.uuidString ?? "bad-habit" 21 | } 22 | } 23 | } 24 | 25 | let item: Item 26 | 27 | var body: some View { 28 | ScrollView { 29 | LazyVStack(alignment: .leading, spacing: Spacing.large) { 30 | switch item { 31 | case .problem: 32 | ProblemView() 33 | case .badHabit(let problem): 34 | BadHabitView(problem: problem) 35 | } 36 | } 37 | .padding(Spacing.standard) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Bad Habits/Views/Update/UpdateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct UpdateView: View { 11 | enum Item: Identifiable { 12 | case problem(Problem) 13 | case badHabit(BadHabit) 14 | 15 | var id: String { 16 | switch self { 17 | case .problem(let problem): 18 | return problem.id?.uuidString ?? "problem" 19 | case .badHabit(let badHabit): 20 | return badHabit.id?.uuidString ?? "bad-habit" 21 | } 22 | } 23 | } 24 | 25 | let item: Item 26 | 27 | var body: some View { 28 | ScrollView { 29 | LazyVStack(alignment: .leading, spacing: Spacing.large) { 30 | switch item { 31 | case .problem(let problem): 32 | ProblemView(problem: problem) 33 | case .badHabit(let badHabit): 34 | BadHabitView(badHabit: badHabit) 35 | } 36 | } 37 | .padding(Spacing.standard) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Bad Habits/Magic/MYRecordConvertible/BadHabit+MYRecordConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BadHabit+CKRecordConvertible.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 16/01/25. 6 | // 7 | 8 | import MYCloudKit 9 | import CloudKit.CKRecord 10 | 11 | extension BadHabit: MYRecordConvertible { 12 | 13 | public var myRecordID: String { 14 | self.id?.uuidString ?? UUID().uuidString 15 | } 16 | 17 | public var myRecordType: String { 18 | "BadHabit" 19 | } 20 | 21 | public var myRootGroupID: String? { 22 | switch AppState.shared.shareType { 23 | case .recordWithMYZone: 24 | return nil 25 | case .zone, .recordWithCustomZone: 26 | return problem?.id?.uuidString 27 | } 28 | } 29 | 30 | public var myParentID: String? { 31 | switch AppState.shared.shareType { 32 | case .recordWithMYZone, .recordWithCustomZone: 33 | return problem?.id?.uuidString 34 | case .zone: 35 | return nil 36 | } 37 | } 38 | 39 | public var myProperties: [String: MYRecordValue] { 40 | [ 41 | "title": .string(title), 42 | "problem": .reference(problem, deleteRule: .deleteSelf), 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Bad Habits/Magic/MYRecordConvertible/Oopsie+MYRecordConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Oopsie+CKRecordConvertible.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 16/01/25. 6 | // 7 | 8 | import CloudKit.CKRecord 9 | import MYCloudKit 10 | 11 | extension Oopsie: MYRecordConvertible { 12 | 13 | public var myRecordID: String { 14 | self.id?.uuidString ?? UUID().uuidString 15 | } 16 | 17 | public var myRecordType: String { 18 | "Oopsie" 19 | } 20 | 21 | public var myRootGroupID: String? { 22 | switch AppState.shared.shareType { 23 | case .recordWithMYZone: 24 | return nil 25 | case .zone, .recordWithCustomZone: 26 | return badHabit?.problem?.id?.uuidString 27 | } 28 | } 29 | 30 | public var myParentID: String? { 31 | switch AppState.shared.shareType { 32 | case .recordWithMYZone, .recordWithCustomZone: 33 | return badHabit?.id?.uuidString 34 | case .zone: 35 | return nil 36 | } 37 | } 38 | 39 | public var myProperties: [String: MYRecordValue] { 40 | [ 41 | "timestamp": .date(timestamp), 42 | "badHabit": .reference(badHabit, deleteRule: .deleteSelf) 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Bad Habits/Extensions/CoreData/Oopsie+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Oopsie+Extension.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import CoreData 9 | 10 | extension Oopsie { 11 | class func fetchRequest(for badHabit: BadHabit) -> NSFetchRequest { 12 | let request = Oopsie.fetchRequest() 13 | request.predicate = .init(format: "badHabit == %@", badHabit) 14 | request.sortDescriptors = [.init(keyPath: \Oopsie.timestamp, ascending: false)] 15 | return request 16 | } 17 | 18 | class func fetchRequest(for badHabit: BadHabit, startDate: Date, endDate: Date) -> NSFetchRequest { 19 | let request = Oopsie.fetchRequest() 20 | request.predicate = .init( 21 | format: "badHabit == %@ AND timestamp >= %@ AND timestamp <= %@", 22 | badHabit, 23 | startDate as NSDate, 24 | endDate as NSDate 25 | ) 26 | request.sortDescriptors = [.init(keyPath: \Oopsie.timestamp, ascending: false)] 27 | return request 28 | } 29 | } 30 | 31 | extension Oopsie { 32 | class func fetchRequest(with id: String) -> NSFetchRequest { 33 | let request = Oopsie.fetchRequest() 34 | if let uuid = UUID(uuidString: id) { 35 | request.predicate = .init(format: "id == %@", uuid as CVarArg) 36 | } 37 | request.fetchLimit = 1 38 | return request 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Bad Habits/Views/Home/ShareView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 09/05/25. 6 | // 7 | 8 | import CloudKit 9 | import SwiftUI 10 | 11 | struct CloudSharingView: UIViewControllerRepresentable { 12 | let share: CKShare 13 | let container: CKContainer 14 | 15 | func makeCoordinator() -> CloudSharingCoordinator { 16 | CloudSharingCoordinator(share: share) 17 | } 18 | 19 | func makeUIViewController(context: Context) -> UICloudSharingController { 20 | let controller = UICloudSharingController(share: share, container: container) 21 | controller.modalPresentationStyle = .formSheet 22 | controller.delegate = context.coordinator 23 | return controller 24 | } 25 | 26 | func updateUIViewController(_ uiViewController: UICloudSharingController, context: Context) { } 27 | } 28 | 29 | final class CloudSharingCoordinator: NSObject, UICloudSharingControllerDelegate { 30 | 31 | let share: CKShare 32 | 33 | init(share: CKShare) { 34 | self.share = share 35 | } 36 | 37 | func itemTitle(for csc: UICloudSharingController) -> String? { 38 | share[CKShare.SystemFieldKey.title] as? String 39 | } 40 | 41 | func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) { 42 | assertionFailure(error.localizedDescription) 43 | } 44 | 45 | func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { } 46 | } 47 | -------------------------------------------------------------------------------- /Bad Habits/Views/Create/CreateView+ProblemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddProblemView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import MYCloudKit 9 | import SwiftUI 10 | 11 | extension CreateView { 12 | struct ProblemView: View { 13 | 14 | @Environment(\.dismiss) private var dismiss 15 | @Environment(\.managedObjectContext) private var managedObjectContext 16 | @State private var title: String = "" 17 | 18 | var body: some View { 19 | TextField("Be honest...", text: $title) 20 | .textFieldStyle(.roundedBorder) 21 | 22 | Button { 23 | let problem = Problem(context: managedObjectContext) 24 | problem.id = UUID() 25 | problem.title = title 26 | 27 | AppState.shared.syncEngine.sync(problem) 28 | 29 | // add force unwrapping to Programming Problems 30 | try! managedObjectContext.save() 31 | 32 | dismiss() 33 | } label: { 34 | Text("Add to the list of problems") 35 | .font(.headline) 36 | .foregroundStyle(.white) 37 | .frame(maxWidth: .infinity) 38 | .frame(height: 50) 39 | .contentShape(Rectangle()) 40 | .background(Color.accentColor) 41 | .clipShape(.rect(cornerRadius: CornerRadius.standard)) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYSyncEngine+Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 08/05/25. 3 | // 4 | 5 | import Foundation 6 | 7 | extension MYSyncEngine { 8 | public enum LogLevel: Int, Comparable { 9 | case debug = 0 10 | case info 11 | case warning 12 | case error 13 | case none 14 | 15 | public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { 16 | lhs.rawValue < rhs.rawValue 17 | } 18 | } 19 | 20 | struct Logger { 21 | 22 | private let currentLevel: LogLevel 23 | 24 | init(currentLevel: LogLevel) { 25 | self.currentLevel = currentLevel 26 | } 27 | 28 | func log(_ message: String, level: LogLevel = .info, error: Error? = nil) { 29 | guard level >= currentLevel else { 30 | return 31 | } 32 | 33 | let symbol: String 34 | switch level { 35 | case .debug: symbol = "🔍" 36 | case .info: symbol = "ℹ️" 37 | case .warning: symbol = "⚠️" 38 | case .error: symbol = "⛔" 39 | case .none: return 40 | } 41 | 42 | print("\(symbol) MYSyncKit: \(message)") 43 | if let error { 44 | print(" ⤷ Error: \(error.localizedDescription)") 45 | } 46 | } 47 | 48 | func log(_ message: String, error: Error) { 49 | log(message, level: .error, error: error) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Bad Habits/Views/Update/UpdateView+ProblemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateView+ProblemView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import MYCloudKit 9 | import SwiftUI 10 | 11 | extension UpdateView { 12 | struct ProblemView: View { 13 | 14 | @Environment(\.dismiss) private var dismiss 15 | @Environment(\.managedObjectContext) private var managedObjectContext 16 | @ObservedObject private var problem: Problem 17 | @State private var title: String 18 | 19 | init(problem: Problem) { 20 | self.problem = problem 21 | self._title = .init(wrappedValue: problem.title ?? "") 22 | } 23 | 24 | var body: some View { 25 | TextField("Be honest...", text: $title) 26 | .textFieldStyle(.roundedBorder) 27 | 28 | Button { 29 | problem.title = title 30 | 31 | AppState.shared.syncEngine.sync(problem) 32 | 33 | // add force unwrapping to Programming Problems 34 | try! managedObjectContext.save() 35 | 36 | dismiss() 37 | } label: { 38 | Text("Update") 39 | .font(.headline) 40 | .foregroundStyle(.white) 41 | .frame(maxWidth: .infinity) 42 | .frame(height: 50) 43 | .contentShape(Rectangle()) 44 | .background(Color.accentColor) 45 | .clipShape(.rect(cornerRadius: CornerRadius.standard)) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Bad Habits/Views/Update/UpdateView+BadHabitView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UpdateView+BadHabitView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import MYCloudKit 9 | import SwiftUI 10 | 11 | extension UpdateView { 12 | struct BadHabitView: View { 13 | 14 | @Environment(\.dismiss) private var dismiss 15 | @Environment(\.managedObjectContext) private var managedObjectContext 16 | @ObservedObject private var badHabit: BadHabit 17 | @State private var title: String 18 | 19 | init(badHabit: BadHabit) { 20 | self.badHabit = badHabit 21 | self._title = .init(wrappedValue: badHabit.title ?? "") 22 | } 23 | 24 | var body: some View { 25 | TextField("Bad \"\(badHabit.problem?.title ?? "")\" habit...", text: $title) 26 | .textFieldStyle(.roundedBorder) 27 | 28 | Button { 29 | badHabit.title = title 30 | 31 | AppState.shared.syncEngine.sync(badHabit) 32 | 33 | // add force unwrapping to Programming Problems 34 | try! managedObjectContext.save() 35 | 36 | dismiss() 37 | } label: { 38 | Text("Update") 39 | .font(.headline) 40 | .foregroundStyle(.white) 41 | .frame(maxWidth: .infinity) 42 | .frame(height: 50) 43 | .contentShape(Rectangle()) 44 | .background(Color.accentColor) 45 | .clipShape(.rect(cornerRadius: CornerRadius.standard)) 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Bad Habits/Views/Create/CreateView+BadHabitView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewBadHabitView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import MYCloudKit 9 | import SwiftUI 10 | 11 | extension CreateView { 12 | struct BadHabitView: View { 13 | 14 | @Environment(\.dismiss) private var dismiss 15 | @Environment(\.managedObjectContext) private var managedObjectContext 16 | let problem: Problem 17 | @State private var title: String = "" 18 | 19 | init(problem: Problem) { 20 | self.problem = problem 21 | } 22 | 23 | var body: some View { 24 | TextField("Bad \"\(problem.title ?? "")\" habit...", text: $title) 25 | .textFieldStyle(.roundedBorder) 26 | 27 | Button { 28 | let badHabit = BadHabit(context: managedObjectContext) 29 | badHabit.id = UUID() 30 | badHabit.title = title 31 | badHabit.problem = problem 32 | 33 | AppState.shared.syncEngine.sync(badHabit) 34 | 35 | // add force unwrapping to Programming Problems 36 | try! managedObjectContext.save() 37 | 38 | dismiss() 39 | } label: { 40 | Text("Add Bad Habit") 41 | .font(.headline) 42 | .foregroundStyle(.white) 43 | .frame(maxWidth: .infinity) 44 | .frame(height: 50) 45 | .contentShape(Rectangle()) 46 | .background(Color.accentColor) 47 | .clipShape(.rect(cornerRadius: CornerRadius.standard)) 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Bad Habits/Bad_Habits.xcdatamodeld/Bad_Habits.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Bad Habits/Bad_HabitsApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bad_HabitsApp.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 14/01/25. 6 | // 7 | 8 | import CloudKit 9 | import MYCloudKit 10 | import SwiftUI 11 | import UserNotifications 12 | 13 | @main 14 | struct Bad_HabitsApp: App { 15 | @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate 16 | private let persistenceController = PersistenceController.shared 17 | 18 | var body: some Scene { 19 | WindowGroup { 20 | HomeView() 21 | .environment(\.managedObjectContext, persistenceController.viewContext) 22 | } 23 | } 24 | } 25 | 26 | final class AppDelegate: NSObject, UNUserNotificationCenterDelegate, UIApplicationDelegate { 27 | 28 | func application( 29 | _ application: UIApplication, 30 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil 31 | ) -> Bool { 32 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert]) { _,_ in } 33 | application.registerForRemoteNotifications() 34 | return true 35 | } 36 | 37 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 38 | let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) 39 | configuration.delegateClass = SceneDelegate.self 40 | return configuration 41 | } 42 | 43 | func application( 44 | _ application: UIApplication, 45 | didReceiveRemoteNotification userInfo: [AnyHashable : Any] 46 | ) async -> UIBackgroundFetchResult { 47 | await AppState.shared.syncEngine.fetch() 48 | return .newData 49 | } 50 | } 51 | 52 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 53 | func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { 54 | // Handle quick actions here 55 | completionHandler(true) 56 | } 57 | 58 | func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) { 59 | Task { 60 | try await AppState.shared.syncEngine.acceptShare( 61 | cloudKitShareMetadata: cloudKitShareMetadata 62 | ) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYCloudEngine+Subscriptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 06/05/25. 3 | // 4 | 5 | import CloudKit.CKSubscription 6 | 7 | extension MYSyncEngine { 8 | 9 | /// Subscribes to silent push notifications for changes in the given CloudKit database scope. 10 | /// 11 | /// CloudKit uses subscriptions to notify the app when records in a database change. 12 | /// This method ensures the app subscribes only once per scope (private/shared) by checking a local flag. 13 | /// It sets `shouldSendContentAvailable` to `true` to receive silent pushes, which are used for background syncing. 14 | /// 15 | /// - Parameter scope: The `CKDatabase.Scope` to subscribe to (e.g., `.private`, `.shared`). 16 | func subscribeToChanges(in scope: CKDatabase.Scope) { 17 | 18 | // Avoid re-subscribing if already done 19 | guard !userDefaults.didSaveSubscription(for: scope) else { 20 | return 21 | } 22 | 23 | // Create a unique subscription for the scope 24 | let subscription = CKDatabaseSubscription(subscriptionID: "changes-\(scope.rawValue)") 25 | 26 | // Configure for silent push (no alert, badge, or sound) 27 | let notification = CKSubscription.NotificationInfo() 28 | notification.shouldSendContentAvailable = true 29 | subscription.notificationInfo = notification 30 | 31 | Task { [weak self] in 32 | guard let self else { return } 33 | 34 | do { 35 | self.logger.log( 36 | "📡 Attempting to subscribe to record changes in '\(scope.name)' scope", 37 | level: .info 38 | ) 39 | 40 | // Register the subscription with CloudKit 41 | try await ckContainer.database(with: scope).save(subscription) 42 | 43 | self.logger.log( 44 | "✅ Successfully subscribed to record changes in '\(scope.name)' scope", 45 | level: .info 46 | ) 47 | 48 | // Save flag so we don't subscribe again unnecessarily 49 | userDefaults.setSavedSubscription(for: scope) 50 | 51 | } catch { 52 | self.logger.log( 53 | "🛑 Failed to subscribe to record changes in '\(scope.name)' scope", 54 | error: error 55 | ) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/Extensions/CKRecord+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 05/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | extension CKRecord { 8 | 9 | /// Initializes a `CKRecord` from previously encoded system fields data. 10 | /// - Parameter data: A `Data` object representing the encoded system fields of a `CKRecord`. 11 | /// - Returns: A new `CKRecord` instance if decoding succeeds; otherwise, `nil`. 12 | convenience init?(data: Data) { 13 | do { 14 | let coder = try NSKeyedUnarchiver(forReadingFrom: data) 15 | coder.requiresSecureCoding = true 16 | self.init(coder: coder) 17 | coder.finishDecoding() 18 | } catch { 19 | return nil 20 | } 21 | } 22 | 23 | /// Encodes only the system fields of the record (metadata used by CloudKit) into `Data`. 24 | /// This is useful for persisting state locally (e.g., before saving or updating in the cloud). 25 | var encodedSystemFields: Data { 26 | let coder = NSKeyedArchiver(requiringSecureCoding: true) 27 | self.encodeSystemFields(with: coder) 28 | return coder.encodedData 29 | } 30 | } 31 | 32 | extension CKRecord { 33 | /// Returns a typed value for the given key from the record. 34 | /// 35 | /// This function attempts to cast the value stored in the record to the specified type `T`. 36 | /// It also includes a special case: if the underlying value is a `CKRecord.Reference` 37 | /// and `T` is `String`, it will return the referenced record's `recordName`. 38 | /// 39 | /// - Parameter key: The key of the field in the `CKRecord`. 40 | /// - Returns: A value of type `T` if the cast is successful, or `nil` if the type does not match or the value is missing. 41 | /// 42 | /// ### Example: 43 | /// ```swift 44 | /// let name: String? = record.value(for: "name") // Regular String 45 | /// let parentID: String? = record.value(for: "parentRef") // Reference → recordName 46 | /// let score: Int? = record.value(for: "score") // Int field 47 | /// ``` 48 | func value(for key: String) -> T? { 49 | let rawValue = self[key] 50 | 51 | // Special case: CKRecord.Reference → String (recordName) 52 | if T.self == String.self, 53 | let reference = rawValue as? CKRecord.Reference { 54 | return reference.recordID.recordName as? T 55 | } 56 | 57 | return rawValue as? T 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Bad Habits/Views/Home/HomeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 14/01/25. 6 | // 7 | 8 | import CoreData 9 | import MYCloudKit 10 | import SwiftUI 11 | 12 | struct HomeView: View { 13 | @FetchRequest( 14 | sortDescriptors: [ 15 | NSSortDescriptor(keyPath: \Problem.title, ascending: true) 16 | ], 17 | animation: .default 18 | ) 19 | private var problems: FetchedResults 20 | @State private var appState: AppState = .shared 21 | 22 | @FetchRequest( 23 | sortDescriptors: [ 24 | NSSortDescriptor(keyPath: \BadHabit.title, ascending: true) 25 | ], 26 | predicate: .init(format: "problem == %@", NSNull()), 27 | animation: .default 28 | ) 29 | private var ungroupedBadHabits: FetchedResults 30 | 31 | var body: some View { 32 | ScrollView { 33 | LazyVStack(spacing: Spacing.large) { 34 | ForEach(problems) { problem in 35 | ProblemSection(problem: problem) 36 | } 37 | 38 | if !ungroupedBadHabits.isEmpty { 39 | Divider() 40 | ForEach(ungroupedBadHabits) { badHabit in 41 | ProblemSection.BadHabitCard(badHabit) 42 | } 43 | } 44 | } 45 | .padding(Spacing.standard) 46 | } 47 | .background(Color(UIColor.systemGroupedBackground).ignoresSafeArea()) 48 | .safeAreaInset(edge: .bottom) { 49 | AddProblemCard() 50 | .padding(.vertical, Spacing.standard) 51 | } 52 | .sheet(item: $appState.sheet) { sheet in 53 | sheet.view 54 | } 55 | .task { 56 | Task { 57 | await AppState.shared.syncEngine.fetch() 58 | } 59 | } 60 | } 61 | } 62 | 63 | extension HomeView { 64 | struct AddProblemCard: View { 65 | 66 | var body: some View { 67 | Button { 68 | AppState.shared.sheet = .create(.problem) 69 | } label: { 70 | HStack { 71 | Image(systemName: "plus") 72 | .foregroundStyle(.accent) 73 | .imageScale(.large) 74 | Text("New Problem") 75 | .foregroundStyle(Color(.label)) 76 | } 77 | .font(.callout) 78 | .padding(.vertical, Spacing.medium) 79 | .padding(.horizontal, Spacing.standard) 80 | .background(.regularMaterial) 81 | .clipShape(Capsule()) 82 | .contentShape(Rectangle()) 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/Helpers/MYRecordConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 05/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | /// A type-safe representation of values that can be stored in a CloudKit record. 8 | /// Used internally by `MYCloudEngine` to encode/decode data for syncing. 9 | /// 10 | /// This enum helps encode your data for storage in `CKRecord`. 11 | /// 12 | public enum MYRecordValue { 13 | case int(Int?) 14 | case double(Double?) 15 | case float(Float?) 16 | case bool(Bool?) 17 | case date(Date?) 18 | case asset(Data?) 19 | case fileURL(URL?) 20 | case string(String?) 21 | 22 | /// Represents a reference to another record conforming to `MYRecordConvertible`, along with its deletion behavior. 23 | case reference((any MYRecordConvertible)?, deleteRule: DeleteRule) 24 | 25 | /// Specifies how a reference behaves when the target record is deleted. 26 | public enum DeleteRule: Codable { 27 | /// No action is taken when the referenced record is deleted. 28 | case none 29 | 30 | /// The current record is deleted if the referenced record is deleted. 31 | case deleteSelf 32 | 33 | /// Maps to CloudKit's `CKRecord.ReferenceAction`. 34 | var referenceAction: CKRecord.ReferenceAction { 35 | switch self { 36 | case .none: return .none 37 | case .deleteSelf: return .deleteSelf 38 | } 39 | } 40 | } 41 | } 42 | 43 | /// A protocol your data models conform to for CloudKit syncing via `MYSyncEngine`. 44 | /// 45 | /// This protocol defines the minimal structure required to translate your model into a `CKRecord` 46 | /// and back. It supports record typing, custom zones (grouping), hierarchical relationships, and 47 | /// all CloudKit-supported field types. 48 | /// 49 | /// Use this when you want to persist and sync data to iCloud across devices and users. 50 | /// 51 | /// ### Example: 52 | /// ```swift 53 | /// struct Task: MYRecordConvertible { 54 | /// let id: String 55 | /// let title: String 56 | /// let project: Project 57 | /// 58 | /// var myRecordID: String { id } 59 | /// var myRecordType: String { "Task" } 60 | /// var myRootGroupID: String? { project.id } // All tasks in the same project go in the same zone 61 | /// var myParentID: String? { nil } 62 | /// 63 | /// var myProperties: [String: MYRecordValue] { 64 | /// [ 65 | /// "title": .string(title), 66 | /// "project": .reference( 67 | /// project, 68 | /// deleteRule: .deleteSelf // when project is deleted, delete the task as well 69 | /// ) 70 | /// ] 71 | /// } 72 | /// } 73 | /// ``` 74 | /// 75 | /// > Tip: Use `myRootGroupID` to group related records into a custom zone — helpful for sharing entire sets. 76 | /// > Use `myParentID` when building a hierarchy, but prefer `.reference` in `myProperties` if you don’t need record-based sharing. 77 | public protocol MYRecordConvertible { 78 | 79 | /// Unique identifier for the CloudKit record, ideally your UUID/Identifier. 80 | var myRecordID: String { get } 81 | 82 | /// The CloudKit record type (e.g., "Task", "User", "Note"). This is whatever your model, class or struct is called. 83 | var myRecordType: String { get } 84 | 85 | /// Optional identifier for the group this record belongs to. 86 | /// 87 | /// Used to organize related records into the same CloudKit zone. 88 | /// For example, tasks in a project or notes in a folder. 89 | /// **Disclaimer**: Do this correctly, thing of it like a tree's root node, all child nodes should provide only the first root node's identifier 90 | var myRootGroupID: String? { get } 91 | 92 | /// Optional identifier for the parent record. 93 | /// 94 | /// Used to model hierarchical relationships like folders and files. 95 | /// Come's handy for sharing records, but you're better off sharing the entire group using `myRootGroupID` 96 | /// (I'll do a better job at documenting this in the future, just ask me if you have any doubts here @mufsasayc) 97 | /// 98 | /// Use sparringly and if it can be a reference in `myProperties`, ideally use that unless you know a little bit of CloudKit and are going to specifically do record sharing instead of zone sharing. 99 | var myParentID: String? { get } 100 | 101 | /// Dictionary of key-value pairs representing the record's fields. 102 | /// 103 | /// Keys are field names; values must be an `MYRecordValue`. 104 | /// Includes support for primitive types, CloudKit references, and assets. 105 | var myProperties: [String: MYRecordValue] { get } 106 | } 107 | 108 | extension MYRecordConvertible { 109 | public var myParentID: String? { nil } 110 | } 111 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYCloudEngine+Share.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 08/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | extension MYSyncEngine { 8 | 9 | /// Creates a `CKShare` for the given record, enabling collaboration via CloudKit sharing. 10 | /// 11 | /// This function handles both zone-based and record-based sharing transparently: 12 | /// 13 | /// - If the record being shared is the root of its group in the `MYRecordConvertible` (i.e. its `myRecordID` is equal to `myRootGroupID`), 14 | /// a **zone-level share** is created. This allows sharing the entire group (zone) of related records, 15 | /// which is ideal when your model represents a group or project (e.g., a shared folder or task list). 16 | /// 17 | /// - Otherwise, a **record-level share** is created using `CKShare(rootRecord:)`, which shares only the specific record 18 | /// and its related hierarchy. CloudKit will include all records that are reachable via `.parent` references. 19 | /// You define the `myParentID` on the `MYRecordConvertible` and `MYCloudKit` sets this hierarchy up for you! 20 | /// So it is important that all related child records in your app properly set the `.parent` field to establish the hierarchy. 21 | /// CloudKit uses this structure to determine what records to include in the shared scope. 22 | /// 23 | /// The function also checks if an existing share already exists for the record and reuses it if possible. 24 | /// Both the main record and its share are saved in a single transaction to ensure consistency. 25 | /// 26 | /// - Parameters: 27 | /// - title: A displayable title for the share (visible in share sheet). 28 | /// - record: The model to be shared, conforming to `MYRecordConvertible`. 29 | /// 30 | /// - Returns: A tuple containing the saved `CKShare` and the associated `CKContainer`. 31 | /// - Throws: An error if the share could not be created or saved. 32 | public func createShare( 33 | with title: String, 34 | for record: any MYRecordConvertible 35 | ) async throws -> (share: CKShare, container: CKContainer) { 36 | 37 | let transaction = getCreateUpdateTransaction(for: record) 38 | 39 | guard let ckRecord = transaction.asCKRecord(using: cache) else { 40 | throw NSError( 41 | domain: "MYSync", 42 | code: 500, 43 | userInfo: [NSLocalizedDescriptionKey: "Could not convert record to CKRecord"] 44 | ) 45 | } 46 | 47 | let databaseScope = transaction.databaseScope(using: cache) 48 | let database = ckContainer.database(with: databaseScope) 49 | var shareRecord: CKShare 50 | 51 | // Reuse existing share if one already exists 52 | if let existingShareID = ckRecord.share?.recordID, 53 | let existingShare = try? await database.record(for: existingShareID) as? CKShare { 54 | shareRecord = existingShare 55 | } else if ckRecord.recordID.recordName == ckRecord.recordID.zoneID.zoneName { 56 | // This handles the case of a CKRecordZone sharing 57 | shareRecord = CKShare(recordZoneID: ckRecord.recordID.zoneID) 58 | } else { 59 | shareRecord = CKShare(rootRecord: ckRecord) 60 | } 61 | 62 | // Assign a visible title for the share 63 | shareRecord[CKShare.SystemFieldKey.title] = title 64 | 65 | // Save both the record and its share in a single transaction 66 | let result = try await database.modifyRecords( 67 | saving: [ckRecord, shareRecord], 68 | deleting: [], 69 | savePolicy: .allKeys 70 | ) 71 | 72 | // Extract and return the updated CKShare 73 | for (key, result) in result.saveResults where key == shareRecord.recordID { 74 | switch result { 75 | case .success(let updatedRecord as CKShare): 76 | return (updatedRecord, ckContainer) 77 | case .success: 78 | return (shareRecord, ckContainer) // Return local share as fallback 79 | case .failure(let error): 80 | throw error 81 | } 82 | } 83 | 84 | throw NSError(domain: "MYSync", code: 404, userInfo: [NSLocalizedDescriptionKey: "Failed to save or locate CKShare"]) 85 | } 86 | 87 | /// Accepts a shared CloudKit record and fetches updated shared data. 88 | /// 89 | /// - Parameter metadata: The `CKShare.Metadata` received via the SceneDelegate. 90 | public func acceptShare(cloudKitShareMetadata metadata: CKShare.Metadata) async throws { 91 | self.logger.log( 92 | "🤝 Accepting CloudKit share", 93 | level: .info 94 | ) 95 | 96 | do { 97 | try await ckContainer.accept(metadata) 98 | self.logger.log( 99 | "🎉 Share accepted successfully", 100 | level: .info 101 | ) 102 | } catch { 103 | self.logger.log( 104 | "🛑 Failed to accept CloudKit share", 105 | level: .error, 106 | error: error 107 | ) 108 | throw error 109 | } 110 | 111 | // Refresh local shared records 112 | try await fetch(in: .shared) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/Extensions/UserDefault+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 06/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | fileprivate struct UserDefaultKey { 8 | static let prefix = "myCloudKitSync" 9 | 10 | // Keys for storing tokens and subscription status 11 | static let privatePreviousServerChangeToken = "\(prefix)_privatePreviousServerChangeToken" 12 | static let sharedPreviousServerChangeToken = "\(prefix)_sharedPreviousServerChangeToken" 13 | static let didSavePrivateSubscription = "\(prefix)_didSavePrivateSubscription" 14 | static let didSaveSharedSubscription = "\(prefix)_didSaveSharedSubscription" 15 | } 16 | 17 | extension UserDefaults { 18 | 19 | /// Stores or retrieves the previous server change token for the private database. 20 | fileprivate var privatePreviousServerChangeToken: CKServerChangeToken? { 21 | get { 22 | guard let tokenString = string(forKey: UserDefaultKey.privatePreviousServerChangeToken), 23 | let data = Data(base64Encoded: tokenString) else { return nil } 24 | return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data) 25 | } 26 | set { 27 | setValue(newValue?.asString(), forKey: UserDefaultKey.privatePreviousServerChangeToken) 28 | } 29 | } 30 | 31 | /// Stores or retrieves the previous server change token for the shared database. 32 | fileprivate var sharedPreviousServerChangeToken: CKServerChangeToken? { 33 | get { 34 | guard let tokenString = string(forKey: UserDefaultKey.sharedPreviousServerChangeToken), 35 | let data = Data(base64Encoded: tokenString) else { return nil } 36 | return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data) 37 | } 38 | set { 39 | setValue(newValue?.asString(), forKey: UserDefaultKey.sharedPreviousServerChangeToken) 40 | } 41 | } 42 | 43 | /// Retrieves the server change token for the given database scope. 44 | func previousServerChangeToken(for scope: CKDatabase.Scope) -> CKServerChangeToken? { 45 | switch scope { 46 | case .private: 47 | return privatePreviousServerChangeToken 48 | case .shared: 49 | return sharedPreviousServerChangeToken 50 | default: 51 | assertionFailure("Unsupported database scope") 52 | } 53 | return nil 54 | } 55 | 56 | /// Stores the server change token for the given database scope. 57 | func setPreviousServerChangeToken(for scope: CKDatabase.Scope, _ value: CKServerChangeToken?) { 58 | switch scope { 59 | case .private: 60 | privatePreviousServerChangeToken = value 61 | case .shared: 62 | sharedPreviousServerChangeToken = value 63 | default: 64 | assertionFailure("Unsupported database scope") 65 | } 66 | } 67 | } 68 | 69 | extension UserDefaults { 70 | 71 | /// Stores the server change token for a specific record zone. 72 | func setServerChangeToken(_ token: CKServerChangeToken?, for zoneID: CKRecordZone.ID) { 73 | let key = "\(UserDefaultKey.prefix)-previousServerChangeToken-\(zoneID.zoneName)-\(zoneID.ownerName)" 74 | setValue(token?.asString(), forKey: key) 75 | } 76 | 77 | /// Retrieves the server change token for a specific record zone. 78 | func getServerChangeToken(for zoneID: CKRecordZone.ID) -> CKServerChangeToken? { 79 | let key = "\(UserDefaultKey.prefix)-previousServerChangeToken-\(zoneID.zoneName)-\(zoneID.ownerName)" 80 | guard let tokenString = string(forKey: key), 81 | let data = Data(base64Encoded: tokenString) else { return nil } 82 | return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: data) 83 | } 84 | } 85 | 86 | extension UserDefaults { 87 | 88 | /// Indicates whether the private subscription has been saved. 89 | fileprivate var didSavePrivateSubscription: Bool { 90 | get { 91 | bool(forKey: UserDefaultKey.didSavePrivateSubscription) 92 | } 93 | set { 94 | setValue(newValue, forKey: UserDefaultKey.didSavePrivateSubscription) 95 | } 96 | } 97 | 98 | /// Indicates whether the shared subscription has been saved. 99 | fileprivate var didSaveSharedSubscription: Bool { 100 | get { 101 | bool(forKey: UserDefaultKey.didSaveSharedSubscription) 102 | } 103 | set { 104 | setValue(newValue, forKey: UserDefaultKey.didSaveSharedSubscription) 105 | } 106 | } 107 | 108 | /// Returns whether a subscription was already saved for a given scope. 109 | func didSaveSubscription(for scope: CKDatabase.Scope) -> Bool { 110 | switch scope { 111 | case .private: 112 | return didSavePrivateSubscription 113 | case .shared: 114 | return didSaveSharedSubscription 115 | default: 116 | assertionFailure("Unsupported database scope") 117 | return false 118 | } 119 | } 120 | 121 | /// Marks that a subscription has been saved for the given scope. 122 | func setSavedSubscription(for scope: CKDatabase.Scope) { 123 | switch scope { 124 | case .private: 125 | didSavePrivateSubscription = true 126 | case .shared: 127 | didSaveSharedSubscription = true 128 | default: 129 | assertionFailure("Unsupported database scope") 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Bad Habits/AppState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppState.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import MYCloudKit 9 | import Foundation 10 | 11 | @Observable 12 | final class AppState { 13 | enum ShareType { 14 | case zone 15 | case recordWithMYZone 16 | case recordWithCustomZone 17 | } 18 | static let shared: AppState = .init() 19 | let shareType: ShareType = .recordWithCustomZone 20 | 21 | var sheet: Sheet? = nil 22 | let syncEngine: MYSyncEngine 23 | 24 | private init() { 25 | self.syncEngine = .init( 26 | containerIdentifier: "iCloud.com.myc.test", 27 | logLevel: .debug 28 | ) 29 | self.syncEngine.delegate = self 30 | } 31 | } 32 | 33 | extension AppState: MYSyncDelegate { 34 | func didReceiveRecordsToSave(_ records: [MYSyncEngine.FetchedRecord]) { 35 | let context = PersistenceController.shared.viewContext 36 | 37 | context.perform { 38 | for record in records { 39 | switch record.type { 40 | case "Problem": 41 | let request = Problem.fetchRequest(with: record.id) 42 | let problem = (try? context.fetch(request).first) ?? Problem(context: context) 43 | problem.id = .init(uuidString: record.id) 44 | problem.title = record.value(for: "title") 45 | case "BadHabit": 46 | let request = BadHabit.fetchRequest(with: record.id) 47 | let badHabit = (try? context.fetch(request).first) ?? BadHabit(context: context) 48 | badHabit.id = .init(uuidString: record.id) 49 | badHabit.title = record.value(for: "title") 50 | 51 | let problemID: String? 52 | switch self.shareType { 53 | case .recordWithMYZone, .recordWithCustomZone: 54 | problemID = record.parentID 55 | case .zone: 56 | problemID = record.value(for: "problem") 57 | } 58 | if let problemID { 59 | let request = Problem.fetchRequest(with: problemID) 60 | let problem = try? context.fetch(request).first 61 | badHabit.problem = problem 62 | } 63 | case "Oopsie": 64 | let request = Oopsie.fetchRequest(with: record.id) 65 | let oopsie = (try? context.fetch(request).first) ?? Oopsie(context: context) 66 | oopsie.id = .init(uuidString: record.id) 67 | oopsie.timestamp = record.value(for: "timestamp") 68 | 69 | let badHabitID: String? 70 | switch self.shareType { 71 | case .recordWithMYZone, .recordWithCustomZone: 72 | badHabitID = record.parentID 73 | case .zone: 74 | badHabitID = record.value(for: "badHabit") 75 | } 76 | 77 | if let badHabitID { 78 | let request = BadHabit.fetchRequest(with: badHabitID) 79 | let badHabit = try? context.fetch(request).first 80 | oopsie.badHabit = badHabit 81 | } 82 | default: 83 | assertionFailure() 84 | } 85 | } 86 | try! context.save() 87 | } 88 | } 89 | 90 | func didReceiveRecordsToDelete(_ records: [(myRecordID: String, myRecordType: MYRecordType)]) { 91 | let context = PersistenceController.shared.viewContext 92 | context.perform { 93 | for record in records { 94 | switch record.myRecordType { 95 | case "Problem": 96 | let request = Problem.fetchRequest(with: record.myRecordID) 97 | if let problem = try? context.fetch(request).first { 98 | context.delete(problem) 99 | } 100 | 101 | case "BadHabit": 102 | let request = BadHabit.fetchRequest(with: record.myRecordID) 103 | if let badHabit = try? context.fetch(request).first { 104 | context.delete(badHabit) 105 | } 106 | 107 | case "Oopsie": 108 | let request = Oopsie.fetchRequest(with: record.myRecordID) 109 | if let oopsie = try? context.fetch(request).first { 110 | context.delete(oopsie) 111 | } 112 | 113 | default: 114 | fatalError() 115 | } 116 | } 117 | 118 | try! context.save() 119 | } 120 | } 121 | 122 | func didReceiveGroupIDsToDelete(_ ids: [String]) { 123 | let context = PersistenceController.shared.viewContext 124 | context.perform { 125 | for id in ids { 126 | let problemRequest = Problem.fetchRequest(with: id) 127 | if let problem = try? context.fetch(problemRequest).first { 128 | context.delete(problem) 129 | } 130 | 131 | let badHabitRequest = BadHabit.fetchRequest(with: id) 132 | if let badHabit = try? context.fetch(badHabitRequest).first { 133 | context.delete(badHabit) 134 | } 135 | } 136 | 137 | try! context.save() 138 | } 139 | } 140 | 141 | func handleUnsyncableRecord( 142 | recordID: String, 143 | recordType: MYRecordType, 144 | reason: String, 145 | error: any Error 146 | ) -> [any MYRecordConvertible]? { 147 | nil 148 | } 149 | 150 | func syncableRecordTypesInDependencyOrder() -> [MYRecordType] { 151 | return ["Problem", "BadHabit", "Oopsie"] 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYSyncDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 09/05/25. 3 | // 4 | 5 | import Foundation 6 | 7 | public typealias MYRecordType = String 8 | 9 | /// A protocol for handling record-level synchronization operations in CloudKit. 10 | /// 11 | /// Conform to this protocol to define how your app interacts with the sync engine to manage CloudKit records. 12 | /// `MYSyncDelegate` allows the sync engine (`MYSyncEngine`) to remain decoupled from your app-specific data models 13 | /// while still performing necessary operations like saving, deleting, and syncing records. 14 | /// 15 | /// By implementing this protocol, your app gains fine-grained control over how records are persisted, deleted, 16 | /// and retried during syncing. This is crucial for managing complex hierarchical relationships between records 17 | /// and ensuring that the app can handle synchronization failures gracefully. 18 | /// 19 | /// ### Methods: 20 | /// - **didReceiveRecordsToSave**: Called when new or updated records are ready to be saved locally. 21 | /// - **didReceiveRecordsToDelete**: Called when records need to be deleted from local storage. 22 | /// - **didReceiveGroupIDsToDelete**: Called when groups need to be removed - a group can be anything that is put as `myRootGroupID` in `MYRecordConvertible`. 23 | /// - **handleUnsyncableRecord**: Handles records that failed to sync, allowing for custom resolution logic and retries, you can see the reason and then add records that need to be synced before it or send over the corrected record and it will put this in place of the current record in the queue position. 24 | /// - **syncableRecordTypesInDependencyOrder**: Returns the list of record types eligible for syncing in the correct order (parent and references before child and records referenced respectably). 25 | /// 26 | /// ### Example Usage: 27 | /// Here’s how you might implement the methods of `MYSyncDelegate`: 28 | /// ```swift 29 | /// class MySyncDelegate: MYSyncDelegate { 30 | /// func didReceiveRecordsToSave(_ records: [MYSyncEngine.FetchedRecord]) { 31 | /// // Save records to the local database 32 | /// } 33 | /// 34 | /// func didReceiveRecordsToDelete(_ records: [(myRecordID: String, myRecordType: MYRecordType)]) { 35 | /// // Delete specified records from the local database 36 | /// } 37 | /// 38 | /// func didReceiveGroupIDsToDelete(_ ids: [String]) { 39 | /// // Delete groups (zones) based on their IDs 40 | /// } 41 | /// 42 | /// func handleUnsyncableRecord(recordID: String, recordType: MYRecordType, reason: String, error: Error) -> [any MYRecordConvertible]? { 43 | /// // Handle failed records, fix and retry syncing them 44 | /// return nil 45 | /// } 46 | /// 47 | /// func syncableRecordTypesInDependencyOrder() -> [MYRecordType] { 48 | /// // Return records ordered by dependency (parents before children) 49 | /// return ["Project", "Task", "Subtask"] 50 | /// } 51 | /// } 52 | /// ``` 53 | /// 54 | /// ### Notes: 55 | /// - **Hierarchy and Dependencies**: When syncing records that have references or hierarchical relationships (like parent-child), 56 | /// the order of records is crucial. Always sync parent records first to ensure referenced records exist when needed. 57 | /// For example, if a `Task` references a `Project`, `Project` should be synced first. 58 | /// 59 | /// - **Handling Sync Failures**: The `handleUnsyncableRecord` method allows you to decide what to do when a record fails to sync, 60 | /// whether you want to attempt to fix the error and retry the sync or ignore it. 61 | public protocol MYSyncDelegate: AnyObject { 62 | 63 | /// Called when new or updated records are ready to be saved locally. 64 | /// 65 | /// - Parameter records: A dictionary where the key is the record type and the value is an array of `MYCloudEngine.Record` instances to be saved. 66 | func didReceiveRecordsToSave(_ records: [MYSyncEngine.FetchedRecord]) 67 | 68 | /// Called when specific records need to be deleted from local storage. 69 | /// 70 | /// - Parameter records: An array of tuples containing `myRecordID` and `myRecordType`, identifying which records to delete. 71 | func didReceiveRecordsToDelete(_ records: [(myRecordID: String, myRecordType: MYRecordType)]) 72 | 73 | /// Called when entire record groups need to be removed (e.g. shared zones or logical groupings, this is basically anything that could have been the `rootGroupID`). 74 | /// 75 | /// - Parameter ids: An array of group identifiers that should be deleted from the local store. 76 | func didReceiveGroupIDsToDelete(_ ids: [String]) 77 | 78 | /// Called when a specific record could not be synced successfully, allowing the delegate to correct and optionally retry syncing it. 79 | /// 80 | /// - Parameters: 81 | /// - id: The unique identifier of the unsyncable record. 82 | /// - type: The record type (usually a CloudKit record type or equivalent). 83 | /// - reason: A developer-readable reason string explaining why syncing failed. 84 | /// - error: The underlying `Error` that caused the sync failure. 85 | /// - Returns: An optional array of fixed records (conforming to `MYRecordConvertible`) to retry syncing, or `nil` to ignore and skip. 86 | func handleUnsyncableRecord( 87 | recordID: String, 88 | recordType: MYRecordType, 89 | reason: String, 90 | error: Error 91 | ) -> [any MYRecordConvertible]? 92 | 93 | /// Returns the list of record types that the app supports for syncing, in hierarchical dependency order. 94 | /// 95 | /// This order ensures that parent records or records referenced by others are synced before child or dependent records. 96 | /// CloudKit requires referenced records to exist before they can be linked to, so ordering is critical for successful sync. 97 | /// 98 | /// For example, if a `Task` references a `Project` record using a `.reference`, then `Project` should appear before `Task`. 99 | /// 100 | /// - Important: Ensure the record type used as `rootGroupID` (typically the zone root) is listed first if it is being shared. 101 | /// 102 | /// - Returns: An array of record type strings, ordered from top-most parent to deepest child. 103 | /// 104 | /// ### Example: 105 | /// ```swift 106 | /// func syncableRecordTypesInDependencyOrder() -> [MYRecordType] { 107 | /// return [ 108 | /// "Project", // zone root / top-level 109 | /// "Task", // references Project 110 | /// "Subtask" // references Task 111 | /// ] 112 | /// } 113 | /// ``` 114 | func syncableRecordTypesInDependencyOrder() -> [MYRecordType] 115 | } 116 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYSyncEngine+Cache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 05/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | extension MYSyncEngine { 8 | /// A singleton responsible for managing all local caching related to CloudKit sync operations, 9 | /// including transaction queues, asset files, encoded system fields, and zone IDs. 10 | class Cache { 11 | typealias Transaction = MYSyncEngine.Transaction 12 | 13 | // Main cache folder in the app's documents directory 14 | private var cacheDirectoryURL: URL { 15 | let documentDirectory = FileManager.default.urls( 16 | for: .documentDirectory, 17 | in: .userDomainMask 18 | ).first! 19 | 20 | return documentDirectory.appendingPathComponent("MYCloudKit") 21 | } 22 | 23 | private var transactionQueueFileURL: URL { 24 | cacheDirectoryURL.appendingPathComponent("transaction_cache.json") 25 | } 26 | 27 | private var zoneIDsFileURL: URL { 28 | cacheDirectoryURL.appendingPathComponent("zoneIDs.json") 29 | } 30 | 31 | private var encodedSystemFieldsDirectoryURL: URL { 32 | cacheDirectoryURL.appendingPathComponent("EncodedSystemFieldsData") 33 | } 34 | 35 | private let fileManager: FileManager = .default 36 | 37 | init() { 38 | // Ensure cache directory exists 39 | if !fileManager.fileExists(atPath: cacheDirectoryURL.path) { 40 | try? fileManager.createDirectory(at: cacheDirectoryURL, withIntermediateDirectories: true) 41 | } 42 | 43 | // Ensure encoded system fields directory exists 44 | if !fileManager.fileExists(atPath: encodedSystemFieldsDirectoryURL.path) { 45 | try? fileManager.createDirectory(at: encodedSystemFieldsDirectoryURL, withIntermediateDirectories: true) 46 | } 47 | } 48 | } 49 | } 50 | 51 | // MARK: - Transactions Queue 52 | 53 | extension MYSyncEngine.Cache { 54 | /// Saves the array of transactions to disk for persistence across launches. 55 | func cacheTransactionQueue(_ transactions: [Transaction]) { 56 | do { 57 | let data = try JSONEncoder().encode(transactions) 58 | try data.write(to: transactionQueueFileURL, options: .atomic) 59 | } catch { 60 | assertionFailure(error.localizedDescription) 61 | } 62 | } 63 | 64 | /// Retrieves the saved transaction queue, or returns an empty array if not found. 65 | func retrieveTransactionQueue() -> [Transaction] { 66 | do { 67 | let data = try Data(contentsOf: transactionQueueFileURL) 68 | let transactions = try JSONDecoder().decode([Transaction].self, from: data) 69 | return transactions 70 | } catch { 71 | return [] 72 | } 73 | } 74 | } 75 | 76 | // MARK: - Asset Storage 77 | 78 | extension MYSyncEngine.Cache { 79 | /// Saves asset data (like images/files) to disk under a transaction-specific folder. 80 | func saveAssetData(_ data: Data, with key: String, for transaction: Transaction) throws -> URL { 81 | let transactionFolderURL = cacheDirectoryURL 82 | .appendingPathComponent("transactions") 83 | .appendingPathComponent(transaction.id.uuidString) 84 | 85 | if !fileManager.fileExists(atPath: transactionFolderURL.path) { 86 | try? fileManager.createDirectory(at: transactionFolderURL, withIntermediateDirectories: true) 87 | } 88 | 89 | let fileURL = transactionFolderURL.appendingPathComponent(key) 90 | try? data.write(to: fileURL) 91 | 92 | return fileURL 93 | } 94 | 95 | /// Deletes cached asset data for a given transaction. 96 | func removeCache(for transaction: Transaction) { 97 | do { 98 | let url = cacheDirectoryURL 99 | .appendingPathComponent("transactions") 100 | .appendingPathComponent(transaction.id.uuidString) 101 | if fileManager.fileExists(atPath: url.path) { 102 | try fileManager.removeItem(atPath: url.path) 103 | } 104 | } catch { 105 | assertionFailure(error.localizedDescription) 106 | } 107 | } 108 | } 109 | 110 | // MARK: - Encoded System Fields 111 | 112 | extension MYSyncEngine.Cache { 113 | /// Stores the encoded system fields of a record (used for preserving CKRecord metadata). 114 | func saveEncodedSystemFields(data: Data, for recordName: String) { 115 | let url = encodedSystemFieldsDirectoryURL 116 | .appendingPathComponent(recordName) 117 | .appendingPathExtension("bin") 118 | do { 119 | try data.write(to: url) 120 | } catch { 121 | assertionFailure(error.localizedDescription) 122 | } 123 | } 124 | 125 | /// Retrieves previously saved system fields data for a record. 126 | func getEncodedSystemFields(for recordName: String) -> Data? { 127 | let url = encodedSystemFieldsDirectoryURL 128 | .appendingPathComponent(recordName) 129 | .appendingPathExtension("bin") 130 | return try? Data(contentsOf: url) 131 | } 132 | } 133 | 134 | // MARK: - Zone ID Caching 135 | 136 | extension MYSyncEngine.Cache { 137 | 138 | fileprivate struct ZoneID: Codable { 139 | let zoneName: String 140 | let ownerName: String 141 | 142 | var asCKRecordZoneID: CKRecordZone.ID { 143 | .init(zoneName: zoneName, ownerName: ownerName) 144 | } 145 | 146 | init(zone: CKRecordZone.ID) { 147 | self.zoneName = zone.zoneName 148 | self.ownerName = zone.ownerName 149 | } 150 | } 151 | 152 | /// Persists a list of CKRecordZone.IDs to disk. 153 | func setZoneIDs(_ zoneIDs: [CKRecordZone.ID]) { 154 | do { 155 | let zones = zoneIDs.map { ZoneID(zone: $0) } 156 | let data = try JSONEncoder().encode(zones) 157 | try data.write(to: zoneIDsFileURL, options: .atomic) 158 | } catch { 159 | assertionFailure(error.localizedDescription) 160 | } 161 | } 162 | 163 | /// Loads previously saved CKRecordZone.IDs, or returns an empty list. 164 | func getZoneIDs() -> [CKRecordZone.ID] { 165 | do { 166 | let data = try Data(contentsOf: zoneIDsFileURL) 167 | let zones = try JSONDecoder().decode([ZoneID].self, from: data) 168 | return zones.map { $0.asCKRecordZoneID } 169 | } catch { 170 | return [] 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYSyncEngine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 05/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | /// `MYSyncEngine` is the core engine responsible for managing all CloudKit sync operations. 8 | /// 9 | /// It abstracts complex CloudKit operations like record creation, updates, deletions, asset handling, 10 | /// retry logic, and sharing into a simple, queue-based interface. 11 | /// 12 | /// ### Responsibilities: 13 | /// - Queues and processes transactions (`Transaction`) for creating, updating, and deleting records. 14 | /// - Maintains `syncState` and `fetchState` for progress tracking and error feedback. 15 | /// - Fetches new and changed records using `fetch()`. 16 | /// - Automatically retries syncs for transient errors (network loss, token expiry, etc.). 17 | /// - Supports `CKShare` generation for record and zone-based collaboration. 18 | /// 19 | /// ### Features: 20 | /// - ✅ Sync to private and shared databases 21 | /// - ✅ Supports custom record zones (via `myRootGroupID`) 22 | /// - ✅ CKAsset file support 23 | /// - ✅ Share individual records or entire zones 24 | /// - ✅ Parent and reference relationship support 25 | /// - ✅ Real-time sync triggers via CloudKit subscriptions 26 | /// - ✅ Configurable retry mechanism (`maxRetryAttempts`) 27 | /// 28 | /// ### Usage: 29 | /// ```swift 30 | /// let syncEngine = MYSyncEngine() 31 | /// syncEngine.delegate = self 32 | /// 33 | /// syncEngine.sync(myTask) // Enqueue for sync 34 | /// syncEngine.delete(myProject) // Delete a record or zone 35 | /// 36 | /// // Observe states 37 | /// syncEngine.$syncState 38 | /// syncEngine.$fetchState 39 | /// ``` 40 | /// 41 | /// ### Notes: 42 | /// - You must implement `MYSyncDelegate` to handle local saves, deletes, and recoveries. 43 | /// - Syncing respects reference and parent-child hierarchy; referenced records must be synced first (this is your responsibility). 44 | /// - For sharing: if `myRecordID == myRootGroupID`, a zone share is created(advisable); otherwise, a record share is made. 45 | /// 46 | /// ### Example Record Share: 47 | /// If you’re syncing a `Task` that references a `Project`, make sure `Project` is synced first and appears earlier 48 | /// in `syncableRecordTypesInDependencyOrder()`. 49 | public final class MYSyncEngine: ObservableObject { 50 | 51 | /// The CloudKit container used for syncing. 52 | let ckContainer: CKContainer 53 | 54 | /// The custom cache used for retrieving encodedSystemFields, the zones on device and other things for a reliable sync 55 | let cache: Cache 56 | 57 | /// Logger for logging logs. Duh! 58 | let logger: Logger 59 | 60 | /// The `UserDefaults` instance for storing tokens and sync metadata. 61 | let userDefaults: UserDefaults 62 | 63 | /// The maximum number of retry attempts for failed sync operations. 64 | let maxRetryAttempts: Int 65 | 66 | /// Represents the state of a sync operation. 67 | public enum SyncState { 68 | case idle 69 | case stopped(queueCount: Int, error: Error?) 70 | case syncing(queueCount: Int) 71 | case completed(date: Date) 72 | 73 | /// Indicates whether a sync operation is currently active. 74 | var isActive: Bool { 75 | switch self { 76 | case .idle, .stopped, .completed: 77 | return false 78 | case .syncing: 79 | return true 80 | } 81 | } 82 | } 83 | 84 | /// Represents the state of a fetch operation. 85 | public enum FetchState { 86 | case idle 87 | case fetching 88 | case stopped(error: Error) 89 | case completed(date: Date) 90 | 91 | /// Indicates whether a fetch operation is currently active. 92 | var isActive: Bool { 93 | switch self { 94 | case .idle, .completed, .stopped: 95 | return false 96 | case .fetching: 97 | return true 98 | } 99 | } 100 | } 101 | 102 | /// Current state of the sync operation, published for UI observation. 103 | @Published public var syncState: SyncState = .idle 104 | 105 | /// Current state of the fetch operation, published for UI observation. 106 | @Published public var fetchState: FetchState = .idle 107 | 108 | /// Queue of pending transactions to be synced. 109 | var queue: [Transaction] { 110 | didSet { 111 | cache.cacheTransactionQueue(queue) 112 | } 113 | } 114 | 115 | /// Optional delegate to receive sync lifecycle callbacks. 116 | public weak var delegate: MYSyncDelegate? 117 | 118 | /// Initializes a new `MYCloudEngine` instance. 119 | /// 120 | /// - Parameters: 121 | /// - containerIdentifier: Optional identifier for a custom CloudKit container. If `nil`, the default container is used. 122 | /// - userDefaultsSuiteName: Optional suite name for using a shared `UserDefaults` instance, useful for app groups or extensions. 123 | /// - maxRetryAttempts: The number of retry attempts before giving up on failed sync attempts. Defaults to 3. 124 | /// - logLevel: The minimum log level to be printed. Defaults to `.debug`. 125 | public init( 126 | containerIdentifier: String? = nil, 127 | userDefaultsSuiteName: String? = nil, 128 | maxRetryAttempts: Int = 3, 129 | logLevel: LogLevel = .debug 130 | ) { 131 | let syncCache: Cache = .init() 132 | self.cache = syncCache 133 | self.queue = syncCache.retrieveTransactionQueue() 134 | self.logger = Logger(currentLevel: logLevel) 135 | // Set the CKContainer to either custom or default. 136 | if let containerIdentifier { 137 | self.logger.log( 138 | "☁️ Using custom CloudKit container '\(containerIdentifier)'", 139 | level: .info 140 | ) 141 | self.ckContainer = .init(identifier: containerIdentifier) 142 | } else { 143 | self.logger.log( 144 | "☁️ Using default CloudKit container", 145 | level: .info 146 | ) 147 | self.ckContainer = .default() 148 | } 149 | 150 | // Use shared UserDefaults if userDefaultsSuiteName is provided, else fall back to standard. 151 | if let userDefaultsSuiteName, let sharedUserDefaults = UserDefaults(suiteName: userDefaultsSuiteName) { 152 | self.logger.log( 153 | "💾 Using UserDefaults with suite '\(userDefaultsSuiteName)'", 154 | level: .info 155 | ) 156 | self.userDefaults = sharedUserDefaults 157 | } else { 158 | self.logger.log( 159 | "💾 Using Standard UserDefaults", 160 | level: .info 161 | ) 162 | self.userDefaults = .standard 163 | } 164 | 165 | self.maxRetryAttempts = maxRetryAttempts 166 | 167 | // Subscribe to private and shared CloudKit database changes for real-time sync triggers. 168 | self.subscribeToChanges(in: .private) 169 | self.subscribeToChanges(in: .shared) 170 | 171 | // Start the initial sync operation. 172 | self.sync() 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYCloudEngine+CRUD.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 06/05/25. 3 | // 4 | 5 | import Foundation 6 | 7 | extension MYSyncEngine { 8 | 9 | /// Creates or updates a transaction for a given record that conforms to `MYRecordConvertible`. 10 | /// - Parameter record: The record that needs to be created or updated. 11 | /// - Returns: A `Transaction` object representing the create/update transaction. 12 | func getCreateUpdateTransaction(for record: any MYRecordConvertible) -> Transaction { 13 | var transaction: Transaction = .init( 14 | id: .init(), 15 | operationType: .createOrUpdate, 16 | record: .init( 17 | recordName: record.myRecordID, 18 | recordType: record.myRecordType, 19 | zoneName: record.myRootGroupID, 20 | parentRecordName: record.myParentID 21 | ), 22 | properties: [:] 23 | ) 24 | 25 | var properties: [String: Transaction.RecordValue] = [:] 26 | 27 | // Mapping properties of the record to transaction properties 28 | for (key, value) in record.myProperties { 29 | switch value { 30 | case .int(let int): 31 | properties.updateValue(.int(int), forKey: key) 32 | case .double(let double): 33 | properties.updateValue(.double(double), forKey: key) 34 | case .float(let float): 35 | properties.updateValue(.float(float), forKey: key) 36 | case .bool(let bool): 37 | properties.updateValue(.bool(bool), forKey: key) 38 | case .date(let date): 39 | properties.updateValue(.date(date), forKey: key) 40 | case .asset(let data): 41 | if let data { 42 | do { 43 | // Saving the asset data and updating the properties with asset URL 44 | let url = try cache.saveAssetData(data, with: key, for: transaction) 45 | properties.updateValue(.asset(url), forKey: key) 46 | } catch { 47 | // If there is an error in saving the asset data, log the error 48 | assertionFailure(error.localizedDescription) 49 | } 50 | } 51 | case .fileURL(let url): 52 | properties.updateValue(.asset(url), forKey: key) 53 | case .string(let string): 54 | properties.updateValue(.string(string), forKey: key) 55 | case .reference(let reference, let deleteRule): 56 | if let reference { 57 | // Creating reference for a related record and adding it to the properties 58 | properties.updateValue( 59 | .reference( 60 | .init( 61 | recordName: reference.myRecordID, 62 | recordType: reference.myRecordType, 63 | zoneName: reference.myRootGroupID, 64 | parentRecordName: nil 65 | ), 66 | deleteRule: deleteRule 67 | ), 68 | forKey: key 69 | ) 70 | } else { 71 | // In case no reference exists, set the delete rule to none 72 | properties.updateValue(.reference(nil, deleteRule: .none), forKey: key) 73 | } 74 | } 75 | } 76 | 77 | // Assigning the mapped properties to the transaction 78 | transaction.properties = properties 79 | 80 | return transaction 81 | } 82 | 83 | /// Syncs the given record 84 | /// - Parameter record: The record to be synchronized. 85 | /// - Note: Make sure the `myRecordType` is defined in the `syncableRecordTypesInDependencyOrder()` 86 | public func sync(_ record: any MYRecordConvertible) { 87 | if let delegate { 88 | assert(delegate.syncableRecordTypesInDependencyOrder().contains(record.myRecordType)) 89 | } 90 | 91 | // Get the transaction for creating or updating the record 92 | let transaction = getCreateUpdateTransaction(for: record) 93 | 94 | // Add the transaction to the queue and trigger the sync operation 95 | self.queue.append(transaction) 96 | 97 | // Log the action of adding the transaction 98 | self.logger.log( 99 | "🌀 Queued sync for \(record.myRecordType) (\(record.myRecordID))", 100 | level: .debug 101 | ) 102 | 103 | self.sync() 104 | } 105 | 106 | /// Deletes the given record and optionally deletes its child records. 107 | /// - Parameters: 108 | /// - record: The record to be deleted. 109 | /// - shouldDeleteChildRecords: A flag indicating whether child records of the given record should also be deleted. Defaults to `false`. 110 | /// Set it to `true` if you this record is the `myParentID` for other records and you want them to all be cascade deleted. 111 | public func delete(_ record: any MYRecordConvertible, shouldDeleteChildRecords: Bool = false) { 112 | if let delegate { 113 | assert(delegate.syncableRecordTypesInDependencyOrder().contains(record.myRecordType)) 114 | } 115 | 116 | let transactionRecord: Transaction.Record = .init( 117 | recordName: record.myRecordID, 118 | recordType: record.myRecordType, 119 | zoneName: record.myRootGroupID, 120 | parentRecordName: record.myParentID 121 | ) 122 | 123 | if record.myRecordID == record.myRootGroupID { 124 | // If the record to be deleted is the root group, delete the zone 125 | let transaction: Transaction = .init( 126 | id: .init(), 127 | operationType: .deleteZone, 128 | record: transactionRecord, 129 | properties: [:] 130 | ) 131 | 132 | self.queue.append(transaction) 133 | } else { 134 | if shouldDeleteChildRecords { 135 | // If the flag is true, delete child records as well 136 | let transaction: Transaction = .init( 137 | id: .init(), 138 | operationType: .deleteChildRecords, 139 | record: transactionRecord, 140 | properties: [:] 141 | ) 142 | 143 | self.queue.append(transaction) 144 | } 145 | 146 | // Delete the record itself 147 | let transaction: Transaction = .init( 148 | id: .init(), 149 | operationType: .deleteRecord, 150 | record: transactionRecord, 151 | properties: [:] 152 | ) 153 | 154 | self.queue.append(transaction) 155 | } 156 | 157 | // Log the action of adding the delete transaction 158 | self.logger.log( 159 | "🗑️ Queued delete for \(record.myRecordType) (\(record.myRecordID))", 160 | level: .debug 161 | ) 162 | 163 | self.sync() 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYCloudEngine+Transaction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 05/05/25. 3 | // 4 | 5 | import CloudKit.CKRecord 6 | 7 | /// A model representing a CloudKit-related operation to be performed, such as creating, updating, or deleting a record or zone. 8 | /// 9 | /// `MYTransaction` instances are created for each sync-related change in the app, cached, and queued for processing. 10 | /// Each transaction captures the operation type, the target record, and a set of property values (key-value pairs). 11 | /// This abstraction allows the system to uniformly handle both sync and delete operations in CloudKit, and retry them if needed. 12 | /// 13 | /// Transactions are typically created using helper functions like `getCreateUpdateTransaction(for:)`, 14 | /// which convert an `MYRecordConvertible` object into a `MYTransaction` with encoded properties. 15 | /// 16 | /// These transactions are used internally to manage a robust and fault-tolerant sync pipeline. 17 | /// 18 | /// ### Example Flow: 19 | /// 1. User creates or modifies data locally. 20 | /// 2. An `MYTransaction` is initialized and added to a queue. 21 | /// 3. The system processes this queue, converting transactions into `CKRecord` objects and syncing them with the `CKContainer`. 22 | /// 23 | /// - Note: All asset data is stored as a temporary file and referenced using a file URL, ensuring compatibility with CloudKit's `CKAsset`. 24 | /// 25 | 26 | extension MYSyncEngine { 27 | struct Transaction: Identifiable, Hashable, Codable { 28 | 29 | typealias Cache = MYSyncEngine.Cache 30 | 31 | /// The type of operation this transaction represents. 32 | enum OperationType: Codable, Hashable { 33 | 34 | /// Creates or updates a `CKRecord` in CloudKit. 35 | case createOrUpdate 36 | 37 | /// Deletes a specific `CKRecord` from CloudKit. 38 | case deleteRecord 39 | 40 | /// Deletes an entire custom zone in CloudKit. 41 | case deleteZone 42 | 43 | /// Deletes all child records under a given parent. 44 | case deleteChildRecords 45 | } 46 | 47 | /// The supported data types that can be stored in a `CKRecord` field. 48 | enum RecordValue: Hashable, Codable { 49 | case int(Int?) 50 | case double(Double?) 51 | case float(Float?) 52 | case bool(Bool?) 53 | case date(Date?) 54 | case asset(URL?) // Represents a file-based asset (image, video, etc.) 55 | case string(String?) 56 | 57 | /// Represents a reference to another record, with an associated delete rule. 58 | case reference(Record?, deleteRule: MYRecordValue.DeleteRule) 59 | } 60 | 61 | let id: UUID 62 | let operationType: OperationType 63 | let record: Record 64 | var properties: [String: RecordValue] 65 | 66 | /// The number of retry attempts made while processing this transaction. Once this is equal to `maxAttempts` passed in the `init` of `MYCloudEngine`, it will be notified via `MYCloudEngineDelegate` and removed from the queue 67 | var attempts: Int = .zero 68 | } 69 | } 70 | 71 | extension MYSyncEngine.Transaction { 72 | struct Record: Hashable, Codable { 73 | let recordName: String 74 | let recordType: String 75 | let zoneName: String? 76 | let parentRecordName: String? 77 | 78 | 79 | /// Builds the base `CKRecord` for the current model, using any previously saved system fields if available. 80 | /// 81 | /// This method attempts to reconstruct the original `CKRecord` using its archived system fields 82 | /// (typically obtained from a previous CloudKit fetch). If those aren't available, it tries to infer 83 | /// the appropriate `CKRecordZone.ID` from referenced records or uses a fallback zone. 84 | /// 85 | /// - Parameter recordReferencingRecordNames: An array of record names this record is referencing. 86 | /// These are used as hints to infer the appropriate `CKRecordZone.ID` if the current record's system fields aren't cached. 87 | /// This is especially useful when the current record is a participant in a shared CKRecord, 88 | /// and we want to place it in the correct zone. 89 | /// 90 | /// - Returns: A `CKRecord` instance for this model. The returned record will have the correct type and ID, 91 | /// and will attempt to reuse or infer the appropriate zone. 92 | /// The caller should update this record with any values that need to be synced to CloudKit. 93 | /// 94 | /// - Note: 95 | /// - If the system fields were saved previously (e.g. after a fetch or save operation), they are preferred to reconstruct the record. 96 | /// - If no system fields are available, the zone is determined based on: 97 | /// 1. The zone of any referenced records (if cached), 98 | /// 2. A predefined `zoneName` that we get from `groupID` from `MYRecordConvertible`, or 99 | /// 3. A fallback default zone named `"MYiCloudZone"`. 100 | /// - If decoding the saved system fields fails, an `assertionFailure` is triggered to help with debugging. 101 | func baseCKRecord( 102 | for recordReferencingRecordNames: [String] = [], 103 | using cache: Cache 104 | ) -> CKRecord { 105 | if let encodedSystemFields = cache.getEncodedSystemFields(for: recordName) { 106 | if let record = CKRecord(data: encodedSystemFields) { 107 | return record 108 | } else { 109 | assertionFailure("unable to reconstruct the CKRecord from the previous encodedSystemFields") 110 | } 111 | } 112 | 113 | let zoneID: CKRecordZone.ID 114 | var referenceZoneID: CKRecordZone.ID? = nil 115 | 116 | let referencedRecordNames = [parentRecordName].compactMap(\.self) + recordReferencingRecordNames 117 | for recordName in referencedRecordNames { 118 | if let data = cache.getEncodedSystemFields(for: recordName), 119 | let zoneID = CKRecord(data: data)?.recordID.zoneID { 120 | referenceZoneID = zoneID 121 | break 122 | } 123 | } 124 | 125 | if let referenceZoneID { 126 | zoneID = referenceZoneID 127 | } else if let zoneName { 128 | if let existingZoneID = cache.getZoneIDs().first(where: { $0.zoneName == zoneName }) { 129 | zoneID = existingZoneID 130 | } else { 131 | zoneID = .init(zoneName: zoneName) 132 | } 133 | } else { 134 | zoneID = .init(zoneName: "MYiCloudZone") 135 | } 136 | 137 | return .init( 138 | recordType: recordType, 139 | recordID: .init(recordName: recordName, zoneID: zoneID) 140 | ) 141 | } 142 | } 143 | } 144 | 145 | extension MYSyncEngine.Transaction { 146 | 147 | /// Determines the appropriate `CKDatabase.Scope` for the current record based on its zone ownership. 148 | /// 149 | /// This property inspects the `CKRecordZone.ID` of the record and returns: 150 | /// - `.private` if the zone is owned by the current user (`CKCurrentUserDefaultName`) 151 | /// - `.shared` otherwise, indicating that the record likely belongs to a shared database (e.g., from a CKShare) 152 | /// 153 | /// - Returns: The CloudKit database scope (`.private` or `.shared`) where this record should be synced. 154 | /// 155 | /// - Note: 156 | /// - This is important for ensuring that the record is saved or fetched from the correct database, 157 | /// especially in apps that support CloudKit sharing. 158 | /// - The logic assumes that if the zone is not owned by the current user, the record belongs to a shared scope. 159 | func databaseScope(using cache: Cache) -> CKDatabase.Scope { 160 | let zoneID = record.baseCKRecord(for: referencingRecordNames, using: cache).recordID.zoneID 161 | 162 | if zoneID.ownerName == CKCurrentUserDefaultName { 163 | return .private 164 | } else { 165 | return .shared 166 | } 167 | } 168 | 169 | /// A list of record names that are referenced by the current transaction's properties. 170 | /// 171 | /// This computed property extracts the `recordName`s from all `.reference` values in the `properties` dictionary, 172 | /// allowing the system to identify dependencies or relationships to other records when building or syncing with CloudKit. 173 | /// 174 | /// - Returns: An array of `String` values representing the record names of all non-nil referenced records. 175 | /// 176 | /// - Note: 177 | /// - This is useful when determining the appropriate `CKRecordZone.ID` for the current record, 178 | /// especially when reconstructing a `CKRecord` that is part of a shared or linked structure. 179 | fileprivate var referencingRecordNames: [String] { 180 | var recordNames: [String] = [] 181 | for value in properties.values { 182 | switch value { 183 | case .reference(let record, _): 184 | if let recordName = record?.recordName { 185 | recordNames.append(recordName) 186 | } 187 | default: 188 | continue 189 | } 190 | } 191 | return recordNames 192 | } 193 | 194 | /// Converts the current `MYTransaction` into a `CKRecord` that is ready to be synced with CloudKit. 195 | /// 196 | /// This method constructs a `CKRecord` by: 197 | /// - Reconstructing the base record using any previously saved system fields or inferred zone information. 198 | /// - Setting the parent record (if applicable) to maintain the hierarchical relationship. 199 | /// - Populating the record's fields with values stored in the `properties` dictionary. 200 | /// 201 | /// The mapping between local properties and `CKRecord` fields supports several types: 202 | /// `Int`, `Double`, `Float`, `Bool`, `Date`, `URL` (as `CKAsset`), `String`, and `CKRecord.Reference`. 203 | /// 204 | /// - Returns: A fully constructed `CKRecord` instance, or `nil` if base record creation fails. 205 | /// 206 | /// - Note: 207 | /// - If the transaction has a parent record, it will be attached as a `.none` action reference. 208 | /// - If a `.reference` property is `nil`, the corresponding field is cleared in the `CKRecord`. 209 | /// - The function assumes that referenced records have valid base `CKRecord`s to derive `recordID`s from. 210 | /// 211 | /// - Warning: 212 | /// - Ensure that asset URLs passed in `.asset` values are valid and accessible, as CloudKit requires the file to be reachable during the upload. 213 | /// - The above is ensured and cleaned up on a successful sync. 214 | /// - All field keys must match those expected by the corresponding CloudKit record type. 215 | func asCKRecord(using cache: Cache) -> CKRecord? { 216 | let ckRecord = record.baseCKRecord(for: referencingRecordNames, using: cache) 217 | 218 | if let parentRecordName = record.parentRecordName { 219 | ckRecord.parent = .init( 220 | recordID: .init( 221 | recordName: parentRecordName, 222 | zoneID: ckRecord.recordID.zoneID 223 | ), 224 | action: .none 225 | ) 226 | } else { 227 | ckRecord.parent = nil 228 | } 229 | 230 | for (key, value) in properties { 231 | switch value { 232 | case .int(let int): 233 | ckRecord[key] = int 234 | case .double(let double): 235 | ckRecord[key] = double 236 | case .float(let float): 237 | ckRecord[key] = float 238 | case .bool(let bool): 239 | ckRecord[key] = bool 240 | case .date(let date): 241 | ckRecord[key] = date 242 | case .asset(let assetURL): 243 | if let assetURL { 244 | ckRecord[key] = CKAsset(fileURL: assetURL) 245 | } 246 | case .string(let string): 247 | ckRecord[key] = string 248 | case .reference(let referencedRecord, let deleteRule): 249 | if let referencedRecord { 250 | let recordID = referencedRecord.baseCKRecord(using: cache).recordID 251 | ckRecord[key] = CKRecord.Reference.init( 252 | recordID: recordID, 253 | action: deleteRule.referenceAction 254 | ) 255 | } else { 256 | ckRecord[key] = nil 257 | } 258 | } 259 | } 260 | 261 | return ckRecord 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /Bad Habits.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | BB6287612DCF3B72007311F8 /* MYCloudKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB6287602DCF3B72007311F8 /* MYCloudKit */; }; 11 | BBC1B75E2DCF788300020AA9 /* MYCloudKit in Frameworks */ = {isa = PBXBuildFile; productRef = BBC1B75D2DCF788300020AA9 /* MYCloudKit */; }; 12 | /* End PBXBuildFile section */ 13 | 14 | /* Begin PBXFileReference section */ 15 | BBBD4C832D36C61400F04551 /* Bad Habits.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Bad Habits.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 16 | /* End PBXFileReference section */ 17 | 18 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 19 | BBBD4C852D36C61400F04551 /* Bad Habits */ = { 20 | isa = PBXFileSystemSynchronizedRootGroup; 21 | path = "Bad Habits"; 22 | sourceTree = ""; 23 | }; 24 | /* End PBXFileSystemSynchronizedRootGroup section */ 25 | 26 | /* Begin PBXFrameworksBuildPhase section */ 27 | BBBD4C802D36C61400F04551 /* Frameworks */ = { 28 | isa = PBXFrameworksBuildPhase; 29 | buildActionMask = 2147483647; 30 | files = ( 31 | BB6287612DCF3B72007311F8 /* MYCloudKit in Frameworks */, 32 | BBC1B75E2DCF788300020AA9 /* MYCloudKit in Frameworks */, 33 | ); 34 | runOnlyForDeploymentPostprocessing = 0; 35 | }; 36 | /* End PBXFrameworksBuildPhase section */ 37 | 38 | /* Begin PBXGroup section */ 39 | BBBD4C7A2D36C61400F04551 = { 40 | isa = PBXGroup; 41 | children = ( 42 | BBBD4C852D36C61400F04551 /* Bad Habits */, 43 | BBBD4C842D36C61400F04551 /* Products */, 44 | ); 45 | sourceTree = ""; 46 | }; 47 | BBBD4C842D36C61400F04551 /* Products */ = { 48 | isa = PBXGroup; 49 | children = ( 50 | BBBD4C832D36C61400F04551 /* Bad Habits.app */, 51 | ); 52 | name = Products; 53 | sourceTree = ""; 54 | }; 55 | /* End PBXGroup section */ 56 | 57 | /* Begin PBXNativeTarget section */ 58 | BBBD4C822D36C61400F04551 /* Bad Habits */ = { 59 | isa = PBXNativeTarget; 60 | buildConfigurationList = BBBD4C962D36C61500F04551 /* Build configuration list for PBXNativeTarget "Bad Habits" */; 61 | buildPhases = ( 62 | BBBD4C7F2D36C61400F04551 /* Sources */, 63 | BBBD4C802D36C61400F04551 /* Frameworks */, 64 | BBBD4C812D36C61400F04551 /* Resources */, 65 | ); 66 | buildRules = ( 67 | ); 68 | dependencies = ( 69 | ); 70 | fileSystemSynchronizedGroups = ( 71 | BBBD4C852D36C61400F04551 /* Bad Habits */, 72 | ); 73 | name = "Bad Habits"; 74 | packageProductDependencies = ( 75 | BB6287602DCF3B72007311F8 /* MYCloudKit */, 76 | BBC1B75D2DCF788300020AA9 /* MYCloudKit */, 77 | ); 78 | productName = "Bad Habits"; 79 | productReference = BBBD4C832D36C61400F04551 /* Bad Habits.app */; 80 | productType = "com.apple.product-type.application"; 81 | }; 82 | /* End PBXNativeTarget section */ 83 | 84 | /* Begin PBXProject section */ 85 | BBBD4C7B2D36C61400F04551 /* Project object */ = { 86 | isa = PBXProject; 87 | attributes = { 88 | BuildIndependentTargetsInParallel = 1; 89 | LastSwiftUpdateCheck = 1600; 90 | LastUpgradeCheck = 1630; 91 | TargetAttributes = { 92 | BBBD4C822D36C61400F04551 = { 93 | CreatedOnToolsVersion = 16.0; 94 | }; 95 | }; 96 | }; 97 | buildConfigurationList = BBBD4C7E2D36C61400F04551 /* Build configuration list for PBXProject "Bad Habits" */; 98 | developmentRegion = en; 99 | hasScannedForEncodings = 0; 100 | knownRegions = ( 101 | en, 102 | Base, 103 | ); 104 | mainGroup = BBBD4C7A2D36C61400F04551; 105 | minimizedProjectReferenceProxies = 1; 106 | packageReferences = ( 107 | BBC1B75C2DCF788300020AA9 /* XCRemoteSwiftPackageReference "MYCloudKit" */, 108 | ); 109 | preferredProjectObjectVersion = 77; 110 | productRefGroup = BBBD4C842D36C61400F04551 /* Products */; 111 | projectDirPath = ""; 112 | projectRoot = ""; 113 | targets = ( 114 | BBBD4C822D36C61400F04551 /* Bad Habits */, 115 | ); 116 | }; 117 | /* End PBXProject section */ 118 | 119 | /* Begin PBXResourcesBuildPhase section */ 120 | BBBD4C812D36C61400F04551 /* Resources */ = { 121 | isa = PBXResourcesBuildPhase; 122 | buildActionMask = 2147483647; 123 | files = ( 124 | ); 125 | runOnlyForDeploymentPostprocessing = 0; 126 | }; 127 | /* End PBXResourcesBuildPhase section */ 128 | 129 | /* Begin PBXSourcesBuildPhase section */ 130 | BBBD4C7F2D36C61400F04551 /* Sources */ = { 131 | isa = PBXSourcesBuildPhase; 132 | buildActionMask = 2147483647; 133 | files = ( 134 | ); 135 | runOnlyForDeploymentPostprocessing = 0; 136 | }; 137 | /* End PBXSourcesBuildPhase section */ 138 | 139 | /* Begin XCBuildConfiguration section */ 140 | BBBD4C942D36C61500F04551 /* Debug */ = { 141 | isa = XCBuildConfiguration; 142 | buildSettings = { 143 | ALWAYS_SEARCH_USER_PATHS = NO; 144 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 145 | CLANG_ANALYZER_NONNULL = YES; 146 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 147 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 148 | CLANG_ENABLE_MODULES = YES; 149 | CLANG_ENABLE_OBJC_ARC = YES; 150 | CLANG_ENABLE_OBJC_WEAK = YES; 151 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 152 | CLANG_WARN_BOOL_CONVERSION = YES; 153 | CLANG_WARN_COMMA = YES; 154 | CLANG_WARN_CONSTANT_CONVERSION = YES; 155 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 156 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 157 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 158 | CLANG_WARN_EMPTY_BODY = YES; 159 | CLANG_WARN_ENUM_CONVERSION = YES; 160 | CLANG_WARN_INFINITE_RECURSION = YES; 161 | CLANG_WARN_INT_CONVERSION = YES; 162 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 163 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 164 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 165 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 166 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 167 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 168 | CLANG_WARN_STRICT_PROTOTYPES = YES; 169 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 170 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 171 | CLANG_WARN_UNREACHABLE_CODE = YES; 172 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 173 | COPY_PHASE_STRIP = NO; 174 | DEBUG_INFORMATION_FORMAT = dwarf; 175 | DEVELOPMENT_TEAM = 6B89WP5H2R; 176 | ENABLE_STRICT_OBJC_MSGSEND = YES; 177 | ENABLE_TESTABILITY = YES; 178 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 179 | GCC_C_LANGUAGE_STANDARD = gnu17; 180 | GCC_DYNAMIC_NO_PIC = NO; 181 | GCC_NO_COMMON_BLOCKS = YES; 182 | GCC_OPTIMIZATION_LEVEL = 0; 183 | GCC_PREPROCESSOR_DEFINITIONS = ( 184 | "DEBUG=1", 185 | "$(inherited)", 186 | ); 187 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 188 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 189 | GCC_WARN_UNDECLARED_SELECTOR = YES; 190 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 191 | GCC_WARN_UNUSED_FUNCTION = YES; 192 | GCC_WARN_UNUSED_VARIABLE = YES; 193 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 194 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 195 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 196 | MTL_FAST_MATH = YES; 197 | ONLY_ACTIVE_ARCH = YES; 198 | SDKROOT = iphoneos; 199 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 200 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 201 | }; 202 | name = Debug; 203 | }; 204 | BBBD4C952D36C61500F04551 /* Release */ = { 205 | isa = XCBuildConfiguration; 206 | buildSettings = { 207 | ALWAYS_SEARCH_USER_PATHS = NO; 208 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 209 | CLANG_ANALYZER_NONNULL = YES; 210 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 211 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 212 | CLANG_ENABLE_MODULES = YES; 213 | CLANG_ENABLE_OBJC_ARC = YES; 214 | CLANG_ENABLE_OBJC_WEAK = YES; 215 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 216 | CLANG_WARN_BOOL_CONVERSION = YES; 217 | CLANG_WARN_COMMA = YES; 218 | CLANG_WARN_CONSTANT_CONVERSION = YES; 219 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 220 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 221 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 222 | CLANG_WARN_EMPTY_BODY = YES; 223 | CLANG_WARN_ENUM_CONVERSION = YES; 224 | CLANG_WARN_INFINITE_RECURSION = YES; 225 | CLANG_WARN_INT_CONVERSION = YES; 226 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 227 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 228 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 229 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 230 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 231 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 232 | CLANG_WARN_STRICT_PROTOTYPES = YES; 233 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 234 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 235 | CLANG_WARN_UNREACHABLE_CODE = YES; 236 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 237 | COPY_PHASE_STRIP = NO; 238 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 239 | DEVELOPMENT_TEAM = 6B89WP5H2R; 240 | ENABLE_NS_ASSERTIONS = NO; 241 | ENABLE_STRICT_OBJC_MSGSEND = YES; 242 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 243 | GCC_C_LANGUAGE_STANDARD = gnu17; 244 | GCC_NO_COMMON_BLOCKS = YES; 245 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 246 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 247 | GCC_WARN_UNDECLARED_SELECTOR = YES; 248 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 249 | GCC_WARN_UNUSED_FUNCTION = YES; 250 | GCC_WARN_UNUSED_VARIABLE = YES; 251 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 252 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 253 | MTL_ENABLE_DEBUG_INFO = NO; 254 | MTL_FAST_MATH = YES; 255 | SDKROOT = iphoneos; 256 | SWIFT_COMPILATION_MODE = wholemodule; 257 | VALIDATE_PRODUCT = YES; 258 | }; 259 | name = Release; 260 | }; 261 | BBBD4C972D36C61500F04551 /* Debug */ = { 262 | isa = XCBuildConfiguration; 263 | buildSettings = { 264 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 265 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 266 | CODE_SIGN_ENTITLEMENTS = "Bad Habits/Bad Habits.entitlements"; 267 | CODE_SIGN_STYLE = Automatic; 268 | CURRENT_PROJECT_VERSION = 1; 269 | ENABLE_PREVIEWS = YES; 270 | GENERATE_INFOPLIST_FILE = YES; 271 | INFOPLIST_FILE = "Bad-Habits-Info.plist"; 272 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 273 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 274 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 275 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 276 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 277 | LD_RUNPATH_SEARCH_PATHS = ( 278 | "$(inherited)", 279 | "@executable_path/Frameworks", 280 | ); 281 | MARKETING_VERSION = 1.0; 282 | PRODUCT_BUNDLE_IDENTIFIER = "mustafa.Bad-Habits"; 283 | PRODUCT_NAME = "$(TARGET_NAME)"; 284 | SWIFT_EMIT_LOC_STRINGS = YES; 285 | SWIFT_VERSION = 5.0; 286 | TARGETED_DEVICE_FAMILY = "1,2"; 287 | }; 288 | name = Debug; 289 | }; 290 | BBBD4C982D36C61500F04551 /* Release */ = { 291 | isa = XCBuildConfiguration; 292 | buildSettings = { 293 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 294 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 295 | CODE_SIGN_ENTITLEMENTS = "Bad Habits/Bad Habits.entitlements"; 296 | CODE_SIGN_STYLE = Automatic; 297 | CURRENT_PROJECT_VERSION = 1; 298 | ENABLE_PREVIEWS = YES; 299 | GENERATE_INFOPLIST_FILE = YES; 300 | INFOPLIST_FILE = "Bad-Habits-Info.plist"; 301 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 302 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 303 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 304 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 305 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 306 | LD_RUNPATH_SEARCH_PATHS = ( 307 | "$(inherited)", 308 | "@executable_path/Frameworks", 309 | ); 310 | MARKETING_VERSION = 1.0; 311 | PRODUCT_BUNDLE_IDENTIFIER = "mustafa.Bad-Habits"; 312 | PRODUCT_NAME = "$(TARGET_NAME)"; 313 | SWIFT_EMIT_LOC_STRINGS = YES; 314 | SWIFT_VERSION = 5.0; 315 | TARGETED_DEVICE_FAMILY = "1,2"; 316 | }; 317 | name = Release; 318 | }; 319 | /* End XCBuildConfiguration section */ 320 | 321 | /* Begin XCConfigurationList section */ 322 | BBBD4C7E2D36C61400F04551 /* Build configuration list for PBXProject "Bad Habits" */ = { 323 | isa = XCConfigurationList; 324 | buildConfigurations = ( 325 | BBBD4C942D36C61500F04551 /* Debug */, 326 | BBBD4C952D36C61500F04551 /* Release */, 327 | ); 328 | defaultConfigurationIsVisible = 0; 329 | defaultConfigurationName = Release; 330 | }; 331 | BBBD4C962D36C61500F04551 /* Build configuration list for PBXNativeTarget "Bad Habits" */ = { 332 | isa = XCConfigurationList; 333 | buildConfigurations = ( 334 | BBBD4C972D36C61500F04551 /* Debug */, 335 | BBBD4C982D36C61500F04551 /* Release */, 336 | ); 337 | defaultConfigurationIsVisible = 0; 338 | defaultConfigurationName = Release; 339 | }; 340 | /* End XCConfigurationList section */ 341 | 342 | /* Begin XCRemoteSwiftPackageReference section */ 343 | BBC1B75C2DCF788300020AA9 /* XCRemoteSwiftPackageReference "MYCloudKit" */ = { 344 | isa = XCRemoteSwiftPackageReference; 345 | repositoryURL = "https://github.com/mufasayc/MYCloudKit.git"; 346 | requirement = { 347 | branch = main; 348 | kind = branch; 349 | }; 350 | }; 351 | /* End XCRemoteSwiftPackageReference section */ 352 | 353 | /* Begin XCSwiftPackageProductDependency section */ 354 | BB6287602DCF3B72007311F8 /* MYCloudKit */ = { 355 | isa = XCSwiftPackageProductDependency; 356 | productName = MYCloudKit; 357 | }; 358 | BBC1B75D2DCF788300020AA9 /* MYCloudKit */ = { 359 | isa = XCSwiftPackageProductDependency; 360 | package = BBC1B75C2DCF788300020AA9 /* XCRemoteSwiftPackageReference "MYCloudKit" */; 361 | productName = MYCloudKit; 362 | }; 363 | /* End XCSwiftPackageProductDependency section */ 364 | }; 365 | rootObject = BBBD4C7B2D36C61400F04551 /* Project object */; 366 | } 367 | -------------------------------------------------------------------------------- /Bad Habits/Views/Home/HomeView+ProblemSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProblemSection.swift 3 | // Bad Habits 4 | // 5 | // Created by Mustafa Yusuf on 15/01/25. 6 | // 7 | 8 | import CloudKit 9 | import MYCloudKit 10 | import SwiftUI 11 | 12 | extension HomeView { 13 | struct ShareContent: Identifiable { 14 | var id: String { share.recordID.recordName } 15 | let share: CKShare 16 | let container: CKContainer 17 | } 18 | 19 | struct ProblemSection: View { 20 | @Environment(\.managedObjectContext) private var managedObjectContext 21 | @ObservedObject private var problem: Problem 22 | @FetchRequest private var badHabits: FetchedResults 23 | 24 | @State private var shareItem: ShareContent? = nil 25 | 26 | init(problem: Problem) { 27 | self.problem = problem 28 | self._badHabits = .init( 29 | fetchRequest: BadHabit.fetchRequest(for: problem), 30 | animation: .smooth 31 | ) 32 | } 33 | 34 | var body: some View { 35 | VStack(alignment: .leading, spacing: Spacing.medium) { 36 | HStack(spacing: .zero) { 37 | Text(.init(problem.title ?? "")) 38 | .font(.largeTitle) 39 | .fontWeight(.bold) 40 | .frame(maxWidth: .infinity, alignment: .leading) 41 | .multilineTextAlignment(.leading) 42 | 43 | Menu { 44 | Button("Edit", systemImage: "pencil") { 45 | AppState.shared.sheet = .update(.problem(problem)) 46 | } 47 | 48 | Button("Add a bad habit", systemImage: "plus") { 49 | AppState.shared.sheet = .create(.badHabit(problem: problem)) 50 | } 51 | 52 | Button("Share", systemImage: "square.and.arrow.up") { 53 | Task { @MainActor in 54 | let result = try await AppState.shared.syncEngine.createShare( 55 | with: problem.title ?? "Untitled Problem", 56 | for: problem 57 | ) 58 | 59 | self.shareItem = .init(share: result.share, container: result.container) 60 | } 61 | } 62 | 63 | Button("Delete Problem (Liar)", systemImage: "trash", role: .destructive) { 64 | AppState.shared.syncEngine.delete(problem) 65 | managedObjectContext.delete(problem) 66 | try! managedObjectContext.save() 67 | } 68 | } label: { 69 | Image(systemName: "ellipsis") 70 | .padding(Spacing.standard) 71 | .contentShape(Rectangle()) 72 | } 73 | } 74 | 75 | LazyVStack(alignment: .leading, spacing: Spacing.standard) { 76 | ForEach(badHabits) { badHabit in 77 | BadHabitCard(badHabit) 78 | } 79 | if badHabits.isEmpty { 80 | AddBadHabitCard(problem: problem) 81 | } 82 | } 83 | } 84 | .sheet(item: $shareItem) { item in 85 | CloudSharingView(share: item.share, container: item.container) 86 | } 87 | } 88 | } 89 | } 90 | 91 | extension HomeView.ProblemSection { 92 | struct BadHabitCard: View { 93 | struct Month: Identifiable, Hashable { 94 | let startDate: Date 95 | let endDate: Date 96 | let days: [Date] 97 | let blankDaysBeforeStart: Int 98 | 99 | var id: Date { startDate } 100 | } 101 | 102 | @Environment(\.managedObjectContext) private var managedObjectContext 103 | @ObservedObject private var badHabit: BadHabit 104 | @FetchRequest private var oopsies: FetchedResults 105 | 106 | @State private var months: [Month] = [] 107 | 108 | @State private var shareItem: HomeView.ShareContent? = nil 109 | 110 | private let rows: [GridItem] = [GridItem].init( 111 | repeating: .init(.flexible(), spacing: 2, alignment: .leading), 112 | count: 7 113 | ) 114 | 115 | init(_ badHabit: BadHabit) { 116 | self.badHabit = badHabit 117 | self._oopsies = .init( 118 | fetchRequest: Oopsie.fetchRequest(for: badHabit), 119 | animation: .smooth 120 | ) 121 | } 122 | 123 | var body: some View { 124 | VStack(alignment: .leading) { 125 | Text(.init(badHabit.title ?? "")) 126 | .font(.headline) 127 | .padding(.trailing, Spacing.standard) 128 | 129 | HStack(spacing: .zero) { 130 | VStack(alignment: .leading, spacing: .zero) { 131 | TitleView(title: "D") 132 | .hidden() 133 | ForEach(Calendar.autoupdatingCurrent.shortWeekdaySymbols, id: \.self) { weekday in 134 | Text(weekday) 135 | .font(.caption) 136 | .foregroundStyle(Color(.tertiaryLabel)) 137 | .padding(.vertical, 4) 138 | } 139 | } 140 | 141 | ScrollView(.horizontal) { 142 | HStack(spacing: Spacing.standard) { 143 | ForEach(months) { month in 144 | VStack(alignment: .trailing, spacing: .zero) { 145 | TitleView(title: month.startDate.monthTitle()) 146 | LazyHGrid( 147 | rows: rows, 148 | alignment: .center, 149 | spacing: 2 150 | ) { 151 | if month.blankDaysBeforeStart > 0 { 152 | ForEach(1...month.blankDaysBeforeStart, id: \.self) { _ in 153 | BlankDayCard() 154 | } 155 | } 156 | ForEach(month.days, id: \.self) { day in 157 | DayView(badHabit: badHabit, day: day) 158 | } 159 | } 160 | .frame(maxHeight: .infinity) 161 | } 162 | } 163 | } 164 | .padding(.horizontal, Spacing.standard) 165 | } 166 | .mask { 167 | LinearGradient( 168 | stops: [ 169 | .init(color: .clear, location: 0.01), 170 | .init(color: .white, location: 0.02), 171 | .init(color: .white, location: 0.95), 172 | .init(color: .clear, location: 1), 173 | ], 174 | startPoint: .leading, 175 | endPoint: .trailing 176 | ) 177 | } 178 | .scrollIndicators(.hidden) 179 | } 180 | } 181 | .padding([.vertical, .leading], Spacing.standard) 182 | .background(Color(UIColor.secondarySystemGroupedBackground)) 183 | .clipShape(.rect(cornerRadius: CornerRadius.standard)) 184 | .task { 185 | months = getLast11Months(from: .now) 186 | } 187 | .contextMenu { 188 | Button("Edit", systemImage: "pencil") { 189 | AppState.shared.sheet = .update(.badHabit(badHabit)) 190 | } 191 | 192 | switch AppState.shared.shareType { 193 | case .recordWithMYZone, .recordWithCustomZone: 194 | 195 | Button("Share", systemImage: "square.and.arrow.up") { 196 | Task { @MainActor in 197 | let result = try await AppState.shared.syncEngine.createShare( 198 | with: badHabit.title ?? "Untitled Bad Habit", 199 | for: badHabit 200 | ) 201 | 202 | self.shareItem = .init(share: result.share, container: result.container) 203 | } 204 | } 205 | case .zone: 206 | EmptyView() 207 | } 208 | Button("Delete Bad Habit 😂", systemImage: "trash", role: .destructive) { 209 | AppState.shared.syncEngine.delete(badHabit) 210 | 211 | managedObjectContext.delete(badHabit) 212 | try! managedObjectContext.save() 213 | } 214 | } 215 | .sheet(item: $shareItem) { item in 216 | CloudSharingView(share: item.share, container: item.container) 217 | } 218 | } 219 | 220 | func getLast11Months(from date: Date) -> [Month] { 221 | var months: [Month] = [] 222 | let calendar = Calendar.autoupdatingCurrent 223 | 224 | for offset in (0...10) { 225 | guard let monthStartDate = calendar.date(byAdding: .month, value: -offset, to: date)?.startOfMonth(), 226 | let monthEndDate = calendar.date(byAdding: .month, value: -offset, to: date)?.endOfMonth() else { 227 | continue 228 | } 229 | 230 | // Calculate days in the month 231 | var days: [Date] = [] 232 | var currentDate = monthStartDate 233 | 234 | while currentDate <= monthEndDate { 235 | days.append(currentDate) 236 | guard let nextDate = calendar.date(byAdding: .day, value: 1, to: currentDate) else { 237 | break 238 | } 239 | currentDate = nextDate 240 | } 241 | 242 | days = days 243 | .filter { $0 <= date } 244 | .reversed() 245 | 246 | // Calculate blank days before the first day of the month 247 | guard let firstDate = days.first else { 248 | continue 249 | } 250 | let weekdayOfFirstDay = calendar.component(.weekday, from: firstDate) 251 | let blankDays = weekdayOfFirstDay - calendar.firstWeekday 252 | 253 | months.append( 254 | .init( 255 | startDate: monthStartDate, 256 | endDate: monthEndDate, 257 | days: days, 258 | blankDaysBeforeStart: blankDays >= 0 ? blankDays : 7 + blankDays 259 | ) 260 | ) 261 | } 262 | 263 | return months 264 | } 265 | 266 | struct TitleView: View { 267 | let title: String 268 | var body: some View { 269 | Text(title) 270 | .font(.footnote) 271 | } 272 | } 273 | } 274 | } 275 | 276 | extension HomeView.ProblemSection { 277 | struct AddBadHabitCard: View { 278 | @ObservedObject var problem: Problem 279 | 280 | var body: some View { 281 | Button { 282 | AppState.shared.sheet = .create(.badHabit(problem: problem)) 283 | } label: { 284 | HStack { 285 | Image(systemName: "plus.circle.fill") 286 | .foregroundStyle(Color(.secondaryLabel)) 287 | 288 | Text("Add a bad \"\(problem.title ?? "")\" habit") 289 | .font(.subheadline) 290 | .foregroundStyle(Color(.label)) 291 | .multilineTextAlignment(.leading) 292 | .frame(maxWidth: .infinity, alignment: .leading) 293 | } 294 | .padding(Spacing.standard) 295 | .background(Color(UIColor.secondarySystemGroupedBackground)) 296 | .clipShape(.rect(cornerRadius: CornerRadius.standard)) 297 | } 298 | } 299 | } 300 | } 301 | 302 | extension HomeView.ProblemSection.BadHabitCard { 303 | struct DayView: View { 304 | @Environment(\.managedObjectContext) private var managedObjectContext 305 | @ObservedObject private var badHabit: BadHabit 306 | @FetchRequest private var oopsies: FetchedResults 307 | 308 | let day: Date 309 | let dayOfMonth: Int 310 | 311 | var backgroundColor: Color { 312 | oopsies.isEmpty ? Color(.tertiarySystemGroupedBackground) : Color.accentColor 313 | } 314 | 315 | var foregroundColor: Color { 316 | oopsies.isEmpty ? Color(.secondaryLabel) : .white 317 | } 318 | 319 | init(badHabit: BadHabit, day: Date) { 320 | self.day = day 321 | let calendar = Calendar.autoupdatingCurrent 322 | let startDate = calendar.startOfDay(for: day) 323 | let endDate = startDate.addingTimeInterval(86399) 324 | self.badHabit = badHabit 325 | self.dayOfMonth = calendar.component(.day, from: startDate) 326 | self._oopsies = .init( 327 | fetchRequest: Oopsie.fetchRequest( 328 | for: badHabit, 329 | startDate: startDate, 330 | endDate: endDate 331 | ), 332 | animation: .smooth 333 | ) 334 | } 335 | 336 | var body: some View { 337 | Button { 338 | guard oopsies.isEmpty else { 339 | oopsies.forEach { oopsie in 340 | AppState.shared.syncEngine.delete(oopsie) 341 | managedObjectContext.delete(oopsie) 342 | } 343 | return 344 | } 345 | let oopsie = Oopsie(context: managedObjectContext) 346 | oopsie.id = UUID() 347 | oopsie.timestamp = day 348 | oopsie.badHabit = badHabit 349 | 350 | AppState.shared.syncEngine.sync(oopsie) 351 | 352 | try! managedObjectContext.save() 353 | } label: { 354 | RoundedRectangle(cornerRadius: 4) 355 | .aspectRatio(1, contentMode: .fit) 356 | .foregroundStyle(backgroundColor) 357 | .overlay { 358 | Text("\(dayOfMonth)") 359 | .font(.caption) 360 | .foregroundStyle(foregroundColor) 361 | } 362 | } 363 | } 364 | } 365 | } 366 | 367 | extension HomeView.ProblemSection.BadHabitCard { 368 | struct BlankDayCard: View { 369 | var body: some View { 370 | Rectangle() 371 | .foregroundStyle(.clear) 372 | .aspectRatio(1, contentMode: .fit) 373 | } 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYCloudEngine+Fetch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 06/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | extension MYSyncEngine { 8 | 9 | /// Asynchronously fetches data from both the private and shared CloudKit databases. 10 | /// 11 | /// - This method updates the `fetchState` property to indicate the current status of the fetch process. 12 | /// - It first sets the state to `.fetching`, then attempts to fetch changes from the `.private` and `.shared` databases. 13 | /// - On success, it sets the state to `.completed` with the current timestamp. 14 | /// - If an error occurs during the fetch, it logs the error and updates the state to `.stopped` with the error. 15 | /// 16 | /// - Note: This method is marked with `@MainActor` to ensure that `fetchState` updates happen on the main thread. 17 | @MainActor 18 | public func fetch() async { 19 | guard let delegate else { 20 | assertionFailure("MYSyncDelegate must be set before fetching data, otherwise you won't be able to save the fetched data.") 21 | return 22 | } 23 | // Set the fetch state to indicate fetching has started 24 | self.fetchState = .fetching 25 | 26 | do { 27 | // Attempt to fetch data from the private CloudKit database 28 | try await self.fetch(in: .private) 29 | 30 | // Attempt to fetch data from the shared CloudKit database 31 | try await self.fetch(in: .shared) 32 | 33 | // If both fetches succeed, update the fetch state with completion time 34 | self.fetchState = .completed(date: .now) 35 | } catch { 36 | // Log the error and update the fetch state to indicate failure 37 | self.logger.log( 38 | "🛑 Fetch operation failed", 39 | error: error 40 | ) 41 | self.fetchState = .stopped(error: error) 42 | } 43 | } 44 | 45 | /// Fetches changes from the specified CloudKit database scope (.private or .shared). 46 | /// 47 | /// - This method checks for changes at the database level first (zones added or deleted), 48 | /// and then fetches record-level changes within those zones. 49 | /// - It updates local caches, collects changed and deleted records, and stores updated change tokens. 50 | /// - Errors such as `CKError.changeTokenExpired` are handled gracefully by resetting the token. 51 | /// 52 | /// - Parameter scope: The `CKDatabase.Scope` to fetch changes from (e.g., `.private` or `.shared`). 53 | /// - Throws: Rethrows errors encountered during the fetch process. 54 | func fetch(in scope: CKDatabase.Scope) async throws { 55 | // Retrieve the last known database change token to fetch deltas 56 | var databaseChangeToken = userDefaults.previousServerChangeToken(for: scope) 57 | var moreComing: Bool 58 | 59 | // Track new and deleted record zones 60 | var newZoneIDs: [CKRecordZone.ID] = [] 61 | var deletedZoneIDs: [CKRecordZone.ID] = [] 62 | 63 | // Temporary storage for records to save and delete 64 | var recordsToSave: [CKRecord] = [] 65 | var recordIDsToDelete: [(record: CKRecord.ID, recordType: CKRecord.RecordType)] = [] 66 | 67 | self.logger.log( 68 | "Starting fetch in \(scope.name) scope", 69 | level: .debug 70 | ) 71 | 72 | let database = ckContainer.database(with: scope) 73 | 74 | // Step 1: Fetch database-level changes (zone creations/deletions) 75 | repeat { 76 | let response = try await database.databaseChanges(since: databaseChangeToken) 77 | deletedZoneIDs = response.deletions.map { $0.zoneID } 78 | newZoneIDs = response.modifications.map { $0.zoneID } 79 | databaseChangeToken = response.changeToken 80 | moreComing = response.moreComing 81 | } while moreComing 82 | 83 | // Step 2: Prepare the full list of zone IDs to fetch record changes from 84 | let existingZoneIDs: [CKRecordZone.ID] = cache.getZoneIDs() 85 | var allZoneIDs = existingZoneIDs + newZoneIDs 86 | 87 | // Filter the zone IDs based on the current scope 88 | allZoneIDs = allZoneIDs.filter { zoneID in 89 | switch scope { 90 | case .private: 91 | return zoneID.ownerName == CKCurrentUserDefaultName 92 | case .shared: 93 | return zoneID.ownerName != CKCurrentUserDefaultName 94 | default: 95 | return false // Skip public database 96 | } 97 | } 98 | 99 | // Step 3: Prepare zone configurations for token-based incremental fetch 100 | typealias ZoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration 101 | var configurationsByRecordZoneID: [CKRecordZone.ID : ZoneConfig] = [:] 102 | 103 | allZoneIDs.forEach { zoneID in 104 | configurationsByRecordZoneID[zoneID] = .init( 105 | previousServerChangeToken: userDefaults.getServerChangeToken(for: zoneID) 106 | ) 107 | } 108 | 109 | var newZoneServerChangeToken: [CKRecordZone.ID: CKServerChangeToken] = [:] 110 | 111 | // Step 4: Fetch record-level changes within the zones 112 | await withCheckedContinuation { continuation in 113 | let operation = CKFetchRecordZoneChangesOperation( 114 | recordZoneIDs: allZoneIDs, 115 | configurationsByRecordZoneID: configurationsByRecordZoneID 116 | ) 117 | 118 | // Always fetch all changes within each zone 119 | operation.fetchAllChanges = true 120 | 121 | // Called when a zone fetch completes 122 | operation.recordZoneFetchResultBlock = { 123 | [weak self] zoneID, 124 | result in 125 | switch result { 126 | case .success((let serverChangeToken, _, let moreComing)): 127 | assert(!moreComing, "Unexpected: moreComing should be false") 128 | newZoneServerChangeToken[zoneID] = serverChangeToken 129 | case .failure(let error): 130 | if let ckError = error as? CKError, 131 | ckError.code == .changeTokenExpired { 132 | // Reset token if expired 133 | self?.userDefaults.setServerChangeToken(nil, for: zoneID) 134 | } 135 | self?.logger.log( 136 | "⚠️ Failed to fetch changes for zone '\(zoneID.zoneName)'", 137 | level: .warning, 138 | error: error 139 | ) 140 | } 141 | } 142 | 143 | // Called for each changed record 144 | operation.recordWasChangedBlock = { 145 | [weak self] recordID, 146 | result in 147 | switch result { 148 | case .success(let record): 149 | recordsToSave.append(record) 150 | case .failure(let error): 151 | self?.logger.log( 152 | "⚠️ Error processing changed record '\(recordID.recordName)'", 153 | level: .warning, 154 | error: error 155 | ) 156 | } 157 | } 158 | 159 | // Called for each deleted record 160 | operation.recordWithIDWasDeletedBlock = { recordID, recordType in 161 | recordIDsToDelete.append((recordID, recordType)) 162 | } 163 | 164 | // Called when change tokens are incrementally updated mid-fetch 165 | operation.recordZoneChangeTokensUpdatedBlock = { 166 | [weak self] zoneID, 167 | token, 168 | _ in 169 | if let token { 170 | newZoneServerChangeToken[zoneID] = token 171 | } else { 172 | self?.logger.log( 173 | "⚠️ Missing token update for zone '\(zoneID.zoneName)'", 174 | level: .warning 175 | ) 176 | } 177 | } 178 | 179 | // Called when the operation finishes 180 | operation.completionBlock = { 181 | continuation.resume() 182 | } 183 | 184 | database.add(operation) 185 | } 186 | 187 | // Step 5: Apply the changes to local storage 188 | self.recordsToSave(recordsToSave) 189 | self.recordsToDelete(recordIDsToDelete) 190 | self.updateZoneIDsCache(newZoneIDs: newZoneIDs, deletedZoneIDs: deletedZoneIDs) 191 | 192 | // Step 6: Persist the new tokens for next sync 193 | userDefaults.setPreviousServerChangeToken(for: scope, databaseChangeToken) 194 | newZoneServerChangeToken.forEach { zoneID, token in 195 | self.userDefaults.setServerChangeToken(token, for: zoneID) 196 | } 197 | 198 | self.logger.log( 199 | "✅ Finished fetch in \(scope.name) scope", 200 | level: .debug 201 | ) 202 | } 203 | 204 | /// Processes and handles a list of CKRecord objects that were successfully saved. 205 | /// 206 | /// This function performs the following steps: 207 | /// 1. Logs the number of records saved. 208 | /// 2. Maps the saved records by their `recordType` into a dictionary for delegation. 209 | /// 3. Notifies the delegate with the grouped records. 210 | /// 4. Caches the encoded system fields of each record for future use. 211 | /// 212 | /// - Parameter records: An array of `CKRecord` objects that have been saved. 213 | func recordsToSave(_ records: [CKRecord]) { 214 | guard !records.isEmpty else { 215 | return 216 | } 217 | 218 | // Group records by their type and convert them into internal Record representations. 219 | var mappedRecords: [String: [FetchedRecord]] = [:] 220 | records.forEach { record in 221 | var recordList = mappedRecords[record.recordType] ?? [] 222 | recordList.append(.init(record: record)) 223 | mappedRecords[record.recordType] = recordList 224 | } 225 | 226 | // Logging. 227 | for (type, records) in mappedRecords { 228 | self.logger.log( 229 | "📥 Received \(records.count) '\(type)' records to save", 230 | level: .debug 231 | ) 232 | } 233 | 234 | var orderedRecords: [FetchedRecord] = [] 235 | 236 | delegate?.syncableRecordTypesInDependencyOrder().forEach { type in 237 | guard let records = mappedRecords[type] else { 238 | return 239 | } 240 | orderedRecords.append(contentsOf: records) 241 | } 242 | 243 | // Notify the delegate about the records to save. 244 | delegate?.didReceiveRecordsToSave(orderedRecords) 245 | 246 | // Cache system fields for each record by record ID. 247 | records.forEach { record in 248 | cache.saveEncodedSystemFields( 249 | data: record.encodedSystemFields, 250 | for: record.recordID.recordName 251 | ) 252 | } 253 | } 254 | 255 | /// Processes and handles a list of CKRecord identifiers that were successfully deleted. 256 | /// 257 | /// This function performs the following: 258 | /// 1. Logs the number of records deleted. 259 | /// 2. Maps the deleted record identifiers and types into a tuple format suitable for downstream use. 260 | /// 3. Notifies the delegate with the deleted record information. 261 | /// 262 | /// - Parameter records: An array of tuples, each containing a `CKRecord.ID` and its corresponding `CKRecord.RecordType`. 263 | func recordsToDelete( 264 | _ records: [( 265 | record: CKRecord.ID, 266 | recordType: CKRecord.RecordType 267 | )] 268 | ) { 269 | guard !records.isEmpty else { 270 | return 271 | } 272 | 273 | // Convert CKRecord.IDs to simple string-based tuples. 274 | let mappedRecords: [(myRecordID: String, myRecordType: MYRecordType)] = records.map { record, recordType in 275 | (record.recordName, recordType) 276 | } 277 | 278 | // Logging. 279 | for (id, type) in mappedRecords { 280 | self.logger.log( 281 | "🗑️ Marked record '\(id)' of type '\(type)' for deletion", 282 | level: .debug 283 | ) 284 | } 285 | 286 | // Notify the delegate about the records to delete. 287 | delegate?.didReceiveRecordsToDelete(mappedRecords) 288 | } 289 | 290 | 291 | /// Updates the local cache of CloudKit zone IDs based on newly fetched and deleted zones. 292 | /// 293 | /// This function performs the following: 294 | /// 1. Logs how many zones were fetched and deleted. 295 | /// 2. Notifies the delegate of zone deletions (typically used to remove groups tied to those zones). 296 | /// 3. Updates the cached list of zone IDs by adding new ones and removing deleted ones. 297 | /// 298 | /// - Parameters: 299 | /// - newZoneIDs: An array of `CKRecordZone.ID` objects representing newly fetched zones. 300 | /// - deletedZoneIDs: An array of `CKRecordZone.ID` objects representing zones that have been deleted. 301 | func updateZoneIDsCache(newZoneIDs: [CKRecordZone.ID], deletedZoneIDs: [CKRecordZone.ID]) { 302 | guard !newZoneIDs.isEmpty || !deletedZoneIDs.isEmpty else { 303 | return 304 | } 305 | 306 | // Log how many zones were fetched and deleted. 307 | self.logger.log( 308 | "📦 Added \(newZoneIDs.count) new zones", 309 | level: .debug 310 | ) 311 | self.logger.log( 312 | "🗑️ Removed \(deletedZoneIDs.count) zones", 313 | level: .debug 314 | ) 315 | 316 | // Inform the delegate about group IDs to delete, using the zone names. 317 | let deletedGroupIDs = deletedZoneIDs.map { $0.zoneName } 318 | self.delegate?.didReceiveGroupIDsToDelete(deletedGroupIDs) 319 | 320 | // Retrieve and update the locally cached zone IDs. 321 | var existingZoneIDs = cache.getZoneIDs() 322 | existingZoneIDs.append(contentsOf: newZoneIDs) 323 | 324 | // Remove any zone IDs that are now deleted. 325 | existingZoneIDs = existingZoneIDs.filter { !deletedZoneIDs.contains($0) } 326 | 327 | // Avoid duplication 328 | existingZoneIDs = Array(Set(existingZoneIDs)) 329 | 330 | // Save the updated zone list back into cache. 331 | self.cache.setZoneIDs(existingZoneIDs) 332 | } 333 | } 334 | 335 | extension MYSyncEngine { 336 | /// A lightweight model representing a CloudKit record (`CKRecord`) used for saving data locally. 337 | /// `FetchedRecord` is a simplified structure that abstracts CloudKit's `CKRecord` by including only essential fields 338 | /// 339 | /// ### Key Properties: 340 | /// - **id**: The unique identifier of the record (same as `myRecordID` in `MYRecordConvertible`). 341 | /// - **type**: same as `myRecordType` in `MYRecordConvertible` (e.g., `"Task"`, `"Project"`). 342 | /// - **rootGroupID**: The identifier of the root group this record belongs to (same as `myRootGroupID` in `MYRecordConvertible`). 343 | /// - **parentID**: The identifier of the parent record (same as `myParentID` in `MYRecordConvertible`). 344 | /// 345 | /// ### Accessing Record Fields: 346 | /// The `FetchedRecord` class provides a type-safe interface for accessing record fields: 347 | /// - Use the `value(for:)` method to access any field in the record, like `String`, `Int`, `Date`, `URL`, or reference IDs. 348 | /// 349 | /// ### Example: 350 | /// ```swift 351 | /// // Creating a FetchedRecord from a CKRecord 352 | /// let record = FetchedRecord(record: ckRecord) 353 | /// 354 | /// // Accessing fields with type-safe value accessors 355 | /// let title: String? = record.value(for: "title") // Accessing a string field 356 | /// let dueDate: Date? = record.value(for: "dueDate") // Accessing a Date field 357 | /// let parentID: String? = record.value(for: "parentID") // Accessing a reference ID (String) 358 | /// ``` 359 | /// 360 | public struct FetchedRecord { 361 | 362 | // The unique identifier of the record (from `recordID.recordName`). 363 | public var id: String 364 | 365 | // The CloudKit record type (often maps to a model or entity name, like "Task", "Project", etc.). 366 | public var type: String 367 | 368 | // The root group or zone ID this record belongs to (i.e., the name of the CloudKit zone). 369 | public var rootGroupID: String? 370 | 371 | // The parent record’s ID, if the record has a hierarchical relationship. 372 | public var parentID: String? 373 | 374 | // The underlying `CKRecord` instance that contains the raw data for the record. 375 | private var ckRecord: CKRecord 376 | 377 | // MARK: - Value Accessors 378 | 379 | /// Returns a typed value for the given key from the record's properties. 380 | /// 381 | /// This method performs type-safe matching based on the expected return type `T`. 382 | /// It supports `Int`, `Double`, `Float`, `Bool`, `Date`, `URL`, `String`, and `String` for reference IDs. 383 | /// 384 | /// - Parameter key: The key for which to retrieve the value. 385 | /// - Returns: A value of type `T` if the key exists and the type matches; otherwise, `nil`. 386 | /// 387 | /// ### Example: 388 | /// ```swift 389 | /// let name: String? = record.value(for: "name") 390 | /// let createdAt: Date? = record.value(for: "createdAt") 391 | /// ``` 392 | public func value(for key: String) -> T? { 393 | ckRecord.value(forKey: key) as? T 394 | } 395 | 396 | // MARK: - Initializer 397 | 398 | /// Initializes a `FetchedRecord` from a `CKRecord`, mapping supported field types. 399 | /// 400 | /// - Parameter record: The CloudKit record to convert. 401 | init(record: CKRecord) { 402 | self.id = record.recordID.recordName 403 | self.type = record.recordType 404 | self.rootGroupID = record.recordID.zoneID.zoneName 405 | self.parentID = record.parent?.recordID.recordName 406 | 407 | self.ckRecord = record 408 | } 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /Frameworks/MYCloudKit/Sources/MYCloudKit/MYSyncEngine/MYCloudEngine+Sync.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Mustafa Yusuf on 06/05/25. 3 | // 4 | 5 | import CloudKit 6 | 7 | extension MYSyncEngine { 8 | /// Syncs the first transaction in the queue to CloudKit. 9 | /// 10 | /// This method ensures serialized syncing of transactions to prevent issues with `CKRecord.Reference` 11 | /// pointing to records that haven't yet been synced. It processes one transaction at a time, based on 12 | /// the first element in the queue. Depending on the type of transaction, it either creates, updates, 13 | /// deletes a record or deletes a zone. 14 | /// 15 | /// Syncing is skipped if another sync is already in progress. On encountering retryable errors like 16 | /// `.zoneNotFound`, it tries to resolve them (e.g. creating the missing zone) and retries the sync. 17 | /// 18 | /// The method updates `syncState` to reflect the current progress and handles retries, caching, and errors. 19 | 20 | func sync() { 21 | /// Linear sync to avoid `CKReference` issues: 22 | /// if a record references another that hasn’t been uploaded, CloudKit will fail. 23 | guard !syncState.isActive else { 24 | return 25 | } 26 | 27 | guard let transaction = queue.first else { 28 | return 29 | } 30 | 31 | Task { @MainActor [weak self] in 32 | guard let self else { 33 | return 34 | } 35 | 36 | /// Convert the transaction to a `CKRecord`. If conversion fails, remove from queue and retry. 37 | guard let ckRecord = transaction.asCKRecord(using: self.cache) else { 38 | self.handleError(NSError(domain: "Cannot parse CKRecord", code: 500), for: transaction) 39 | self.sync() 40 | return 41 | } 42 | 43 | let databaseScope = transaction.databaseScope(using: cache) 44 | let database = self.ckContainer.database(with: databaseScope) 45 | self.syncState = .syncing(queueCount: self.queue.count) 46 | 47 | var retry = false 48 | var lastError: Error? 49 | 50 | do { 51 | switch transaction.operationType { 52 | 53 | case .createOrUpdate: 54 | self.logger.log( 55 | "🌀 Syncing record '\(ckRecord.recordType)' (\(ckRecord.recordID.recordName))", 56 | level: .debug 57 | ) 58 | 59 | let result = try await database.modifyRecords( 60 | saving: [ckRecord], 61 | deleting: [], 62 | savePolicy: .allKeys 63 | ) 64 | 65 | if let (_, value) = result.saveResults.first { 66 | switch value { 67 | case .success(let record): 68 | /// Save system fields for future use (for efficient delta sync, conflict resolution, etc.) 69 | self.cache.saveEncodedSystemFields( 70 | data: record.encodedSystemFields, 71 | for: record.recordID.recordName 72 | ) 73 | self.logger.log( 74 | "✅ Successfully synced '\(ckRecord.recordType)' (\(ckRecord.recordID.recordName))", 75 | level: .debug 76 | ) 77 | 78 | case .failure(let error): 79 | lastError = error 80 | 81 | if let error = error as? CKError { 82 | switch error.code { 83 | case .zoneNotFound, .userDeletedZone: 84 | /// Zone missing – likely first time syncing. Create it and retry. 85 | self.logger.log( 86 | "📦 Zone '\(ckRecord.recordID.zoneID.zoneName)' not found — attempting to create it", 87 | level: .warning 88 | ) 89 | do { 90 | try await database.save(.init(zoneID: ckRecord.recordID.zoneID)) 91 | self.logger.log( 92 | "✅ Created zone '\(ckRecord.recordID.zoneID.zoneName)'", 93 | level: .debug 94 | ) 95 | retry = true 96 | } catch { 97 | self.logger.log( 98 | "🛑 Failed to create zone '\(ckRecord.recordID.zoneID.zoneName)'", 99 | error: error 100 | ) 101 | self.handleError(error, for: transaction) 102 | } 103 | default: 104 | self.logger.log( 105 | "🛑 Failed to sync '\(ckRecord.recordType)' (\(ckRecord.recordID.recordName))", 106 | error: error 107 | ) 108 | self.handleError(error, for: transaction) 109 | } 110 | } else { 111 | self.logger.log( 112 | "🛑 Failed to sync '\(ckRecord.recordType)' (\(ckRecord.recordID.recordName))", 113 | error: error 114 | ) 115 | self.handleError(error, for: transaction) 116 | } 117 | } 118 | } 119 | 120 | case .deleteRecord: 121 | self.logger.log( 122 | "🗑️ Deleting record '\(ckRecord.recordID.recordName)'", 123 | level: .debug 124 | ) 125 | try await database.deleteRecord(withID: ckRecord.recordID) 126 | self.logger.log( 127 | "✅ Successfully deleted '\(ckRecord.recordType)' (\(ckRecord.recordID.recordName))", 128 | level: .debug 129 | ) 130 | 131 | case .deleteChildRecords: 132 | self.logger.log( 133 | "🧹 Deleting child records for '\(ckRecord.recordID.recordName)'", 134 | level: .debug 135 | ) 136 | try await cascadeDeleteChildRecords( 137 | for: ckRecord.recordID, 138 | in: databaseScope 139 | ) 140 | self.logger.log( 141 | "✅ Successfully deleted child records for '\(ckRecord.recordID.recordName)'", 142 | level: .debug 143 | ) 144 | 145 | case .deleteZone: 146 | self.logger.log( 147 | "🗑️ Deleting zone '\(ckRecord.recordID.zoneID.zoneName)'", 148 | level: .debug 149 | ) 150 | try await database.deleteRecordZone(withID: ckRecord.recordID.zoneID) 151 | self.logger.log( 152 | "✅ Successfully deleted zone '\(ckRecord.recordID.zoneID.zoneName)'", 153 | level: .debug 154 | ) 155 | } 156 | 157 | } catch { 158 | lastError = error 159 | self.logger.log( 160 | "🛑 Transaction failed during sync", 161 | error: error 162 | ) 163 | self.handleError(error, for: transaction) 164 | } 165 | 166 | /// Post-sync state update and retry logic 167 | if let lastError { 168 | self.syncState = .stopped(queueCount: self.queue.count, error: lastError) 169 | 170 | if retry { 171 | self.sync() 172 | } 173 | 174 | } else { 175 | /// Remove cache and transaction only if the sync succeeded 176 | cache.removeCache(for: transaction) 177 | if let index = queue.firstIndex(of: transaction) { 178 | self.queue.remove(at: index) 179 | } 180 | 181 | self.syncState = .completed(date: .now) 182 | self.sync() // Proceed to next transaction 183 | } 184 | } 185 | } 186 | } 187 | 188 | extension MYSyncEngine { 189 | /// Handles CloudKit-related errors that occur during a transaction and determines how to proceed. 190 | /// 191 | /// This method interprets the `CKError` (or a general `Error`), categorizes it, 192 | /// and either retries the transaction, drops it permanently, or prepares a fix 193 | /// by fetching referenced records if needed. 194 | /// 195 | /// - Parameters: 196 | /// - error: The `Error` encountered while syncing the transaction. 197 | /// - transaction: The `MYTransaction` representing the current sync operation. 198 | func handleError(_ error: Error, for transaction: Transaction) { 199 | 200 | /// Internal enum to categorize how to handle different error types. 201 | enum KindOfError { 202 | case retryWithoutError // Retry silently without logging 203 | case retryWithError // Retry with error tracking and retry limit 204 | case dontSyncThis // Drop from queue and attempt to recover or skip 205 | } 206 | 207 | let reason: String 208 | let errorKind: KindOfError 209 | 210 | // Special handling for CloudKit errors 211 | if let error = error as? CKError { 212 | switch error.code { 213 | 214 | // These are unexpected here — handled earlier in sync 215 | case .zoneNotFound, .userDeletedZone: 216 | reason = "None" 217 | errorKind = .retryWithoutError 218 | assertionFailure(error.localizedDescription) 219 | 220 | // Retryable errors — transient issues like network/server problems 221 | case .accountTemporarilyUnavailable, .networkUnavailable, .networkFailure, 222 | .serverResponseLost, .zoneBusy, .serviceUnavailable, .requestRateLimited, 223 | .operationCancelled, .notAuthenticated: 224 | reason = "None" 225 | errorKind = .retryWithoutError 226 | 227 | // Setup/config errors — dev needs to fix 228 | case .badContainer, .badDatabase, .missingEntitlement: 229 | reason = "None" 230 | errorKind = .retryWithoutError 231 | assertionFailure(error.localizedDescription) 232 | 233 | // Invalid data — usually due to unsynced references 234 | case .invalidArguments: 235 | reason = "Invalid Arguments — this record has an unsynced reference. Return the referenced records and try syncing again." 236 | errorKind = .dontSyncThis 237 | 238 | // Unexpected — only one record is synced at a time 239 | case .partialFailure: 240 | reason = "Partial Failure — this shouldn't happen." 241 | errorKind = .retryWithError 242 | 243 | // CloudKit not supported by user's iCloud account 244 | case .managedAccountRestricted: 245 | reason = "User's account doesn't have access to CloudKit." 246 | errorKind = .retryWithoutError 247 | 248 | // Permissions issue for current user/account 249 | case .permissionFailure: 250 | reason = "User doesn't have permission to modify this record." 251 | errorKind = .dontSyncThis 252 | 253 | // Shouldn’t occur in transaction-based sync 254 | case .alreadyShared, .participantMayNeedVerification, .tooManyParticipants: 255 | reason = "Share failure — should not apply to transactions." 256 | errorKind = .dontSyncThis 257 | assertionFailure(error.localizedDescription) 258 | 259 | // Asset issues — usually should’ve been cleaned up after successful sync 260 | case .assetFileNotFound, .assetFileModified, .assetNotAvailable: 261 | reason = "Asset error — file was not found or has changed. Retry the sync." 262 | errorKind = .retryWithError 263 | 264 | // Save conflict between device and server record 265 | case .serverRecordChanged: 266 | reason = "Record conflict between server and device." 267 | errorKind = .retryWithError 268 | assertionFailure(error.localizedDescription) 269 | 270 | // Referenced record is missing in CloudKit 271 | case .referenceViolation: 272 | reason = "Reference violation — record references another that isn’t synced. Return the referenced record(s) and try again." 273 | errorKind = .dontSyncThis 274 | 275 | // Schema issues — field constraints or requirements not met 276 | case .constraintViolation: 277 | reason = "Constraint violation — check CloudKit Dashboard for required fields or rules not adhered to." 278 | errorKind = .dontSyncThis 279 | 280 | // iCloud quota/limit issues 281 | case .quotaExceeded: 282 | reason = "Quota exceeded — iCloud storage full." 283 | errorKind = .retryWithoutError 284 | 285 | case .limitExceeded: 286 | reason = "Limit exceeded." 287 | errorKind = .retryWithoutError 288 | 289 | // Sync tokens no longer valid 290 | case .changeTokenExpired: 291 | reason = "Change token has expired." 292 | errorKind = .retryWithError 293 | 294 | // Item doesn’t exist anymore 295 | case .unknownItem: 296 | reason = "Unknown item — possibly deleted or inaccessible." 297 | errorKind = .retryWithError 298 | 299 | case .internalError: 300 | reason = "Internal CloudKit error — rare." 301 | errorKind = .retryWithoutError 302 | 303 | case .incompatibleVersion: 304 | reason = "Incompatible CloudKit version — possibly outdated Xcode or SDK." 305 | errorKind = .retryWithoutError 306 | 307 | case .resultsTruncated: 308 | reason = "CloudKit response too large — truncated." 309 | errorKind = .retryWithError 310 | 311 | case .serverRejectedRequest: 312 | reason = "Server rejected request multiple times." 313 | errorKind = .retryWithError 314 | 315 | case .batchRequestFailed: 316 | reason = "Batch request failed — shouldn't happen (we sync one record at a time)." 317 | errorKind = .retryWithError 318 | 319 | // Catch-all for unknown CKError codes 320 | @unknown default: 321 | reason = "@unknown CKError — please investigate." 322 | errorKind = .retryWithError 323 | } 324 | 325 | } else { 326 | // Non-CKError — retry with logging 327 | reason = error.localizedDescription 328 | errorKind = .retryWithError 329 | } 330 | 331 | /// Removes the transaction from the queue and informs the delegate. 332 | /// If the error was due to missing references, re-enqueues those first. 333 | func removeTransactionFromQueue() { 334 | if let index = queue.firstIndex(of: transaction) { 335 | if let recordsToSync = delegate?.handleUnsyncableRecord( 336 | recordID: transaction.record.recordName, 337 | recordType: transaction.record.recordType, 338 | reason: reason, 339 | error: error 340 | ) { 341 | let transactions = recordsToSync.map { record in 342 | getCreateUpdateTransaction(for: record) 343 | } 344 | queue.insert(contentsOf: transactions, at: index) 345 | } else { 346 | cache.removeCache(for: transaction) 347 | queue.remove(at: index) 348 | } 349 | } else { 350 | assertionFailure("Transaction not found in queue.") 351 | } 352 | } 353 | 354 | // Handle the error based on its category 355 | switch errorKind { 356 | case .retryWithoutError: 357 | // Do nothing, transaction stays in queue and will retry 358 | break 359 | 360 | case .retryWithError: 361 | if let index = queue.firstIndex(of: transaction) { 362 | if queue[index].attempts >= maxRetryAttempts { 363 | removeTransactionFromQueue() 364 | } else { 365 | queue[index].attempts += 1 366 | } 367 | } else { 368 | assertionFailure("Transaction not found in queue.") 369 | } 370 | 371 | case .dontSyncThis: 372 | removeTransactionFromQueue() 373 | } 374 | } 375 | } 376 | 377 | extension MYSyncEngine { 378 | 379 | /// Recursively deletes all child records of a given parent `CKRecord.ID` across all record types. 380 | /// 381 | /// This function uses a breadth-first traversal to find all records that have the given record as their parent, 382 | /// and deletes them in a cascading manner. It relies on a `MYCloudEngineDelegate` to provide all record types. 383 | /// 384 | /// - Parameters: 385 | /// - recordID: The parent `CKRecord.ID` whose child records need to be deleted. 386 | /// - scope: The `CKDatabase.Scope` where the records exist (`.private`, `.shared`, etc.). 387 | /// - Throws: Any errors thrown during record querying or deletion. 388 | /// 389 | /// > relevant record types are returned by the delegate's `syncableRecordTypesInDependencyOrder()` method. 390 | private func cascadeDeleteChildRecords(for recordID: CKRecord.ID, in scope: CKDatabase.Scope) async throws { 391 | guard let recordTypes = delegate?.syncableRecordTypesInDependencyOrder() else { 392 | return 393 | } 394 | 395 | let database = ckContainer.database(with: scope) 396 | 397 | try await withThrowingTaskGroup(of: Void.self) { [weak self] group in 398 | for recordType in recordTypes { 399 | group.addTask { 400 | let query = CKQuery(recordType: recordType, predicate: NSPredicate(format: "parent == %@", recordID)) 401 | let childRecordIDs = (try await self?.getRecordIDs(with: query, zoneID: recordID.zoneID, scope: scope) ?? []) 402 | 403 | try await withThrowingTaskGroup(of: Void.self) { childGroup in 404 | for childRecordID in childRecordIDs { 405 | childGroup.addTask { 406 | try await self?.cascadeDeleteChildRecords(for: childRecordID, in: scope) 407 | } 408 | } 409 | try await childGroup.waitForAll() 410 | } 411 | 412 | if !childRecordIDs.isEmpty { 413 | _ = try await database.modifyRecords(saving: [], deleting: childRecordIDs) 414 | } 415 | } 416 | } 417 | 418 | try await group.waitForAll() 419 | } 420 | } 421 | 422 | /// Recursively fetches all matching `CKRecord.ID`s for a given query in a specific zone and database scope. 423 | /// 424 | /// This function handles CloudKit pagination using `CKQueryOperation.Cursor` under the hood. 425 | /// 426 | /// - Parameters: 427 | /// - query: The `CKQuery` defining the predicate and sort descriptors for fetching records. 428 | /// - cursor: An optional `CKQueryOperation.Cursor` if continuing a paginated query (used in recursion). 429 | /// - zoneID: The `CKRecordZone.ID` indicating the zone to query within. 430 | /// - scope: The `CKDatabase.Scope` (`.private` or `.shared`) determining the target database. We are not supporting `public` as of now 431 | /// - Returns: An array of `CKRecord.ID`. 432 | /// - Throws: Rethrows any errors from the CKOperations 433 | private func getRecordIDs( 434 | with query: CKQuery, 435 | cursor: CKQueryOperation.Cursor? = nil, 436 | zoneID: CKRecordZone.ID, 437 | scope: CKDatabase.Scope 438 | ) async throws -> [CKRecord.ID] { 439 | 440 | var recordIDs: [CKRecord.ID] = [] 441 | let database = ckContainer.database(with: scope) 442 | 443 | let result: (matchResults: [(CKRecord.ID, Result)], queryCursor: CKQueryOperation.Cursor?) 444 | 445 | if let cursor { 446 | // Continue from the previous query cursor 447 | result = try await database.records(continuingMatchFrom: cursor, desiredKeys: []) 448 | } else { 449 | // Start a fresh query in the given zone 450 | result = try await database.records(matching: query, inZoneWith: zoneID, desiredKeys: []) 451 | } 452 | 453 | // Collect all matched record IDs 454 | recordIDs.append(contentsOf: result.matchResults.map { $0.0 }) 455 | 456 | // If there's a cursor, recurse to fetch the next batch 457 | if let nextCursor = result.queryCursor { 458 | let moreIDs = try await getRecordIDs( 459 | with: query, 460 | cursor: nextCursor, 461 | zoneID: zoneID, 462 | scope: scope 463 | ) 464 | recordIDs.append(contentsOf: moreIDs) 465 | } 466 | 467 | return recordIDs 468 | } 469 | } 470 | --------------------------------------------------------------------------------