├── 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 |
--------------------------------------------------------------------------------