Apr 25, 2026

How I Got CloudKit Sharing Right by Not Touching My Real App

You know that moment when you realize the whole point of your app only works if other people can actually use it with you?

That’s where I was with Baile. A family planning app — groceries, events, meals, tasks. Four weeks into learning Swift. Three users on TestFlight: me and my family.

The idea was simple. Anyone in the family should be able to add something to the grocery list, check it off, see what everyone else sees. One shared picture. No “did you add milk?” texts.

CloudKit felt like the natural fit. Already on Apple’s stack. But the moment I started working with CKShare and shared zones, it became clear this wasn’t something I could just try out in an afternoon. I realised I had leapt without looking.

I Didn’t Know What I Was Doing. I Admitted That Early.

I’m on my fourth iOS app. Swift is still relatively new to me. I had a SwiftData model that was taking shape, and CloudKit sharing isn’t a feature you sprinkle on top. It changes how zones work, how ownership behaves, how sync happens. Get it wrong and you’re debugging corrupted state with no easy rollback.

Learning all of that while simultaneously not breaking four weeks of work felt like the wrong tradeoff.

So I stepped back.

ticklist: A Lab, Not a Product

I built a fifth app from scratch. One job: add and remove items to a checklist and share it across iCloud accounts. No business logic. No SwiftData model to protect. Nothing at stake.

I called it ticklist.

The goal was never to ship it. The goal was to make every mistake I was going to make anyway, in a place where those mistakes cost nothing. Four to five sessions over a week.

And I made them.

What the Lab Taught Me

Six milestones. Each one surfaced something I hadn’t anticipated.

CKSyncEngine silently fails if you initialize it wrong

Cross-account sharing seemed like the easy start. Invite someone, they accept, both of you see the same list. Except CKSyncEngine has a specific initialization flow, and if you get it slightly wrong your first sync silently fails. No error. No crash. Just an empty list and a lot of confusion. That one took a while to figure out.

Live sync is about understanding when to reconcile

Getting real-time updates right meant understanding how CKSyncEngine batches and delivers changes, and more importantly when it expects you to reconcile local state versus remote state. The API doesn’t hold your hand here. You have to reason about it.

Conflict resolution is a product decision, not just a technical one

Last-write-wins is simple but wrong for a shared checklist. Two people editing the same item at the same time should merge, not stomp. We ended up with per-field rules: last-write-wins for text fields, OR-semantics for booleans like isDone (if either person checked it, it stays checked), and earliest-wins for createdAt. Building this in ticklist first meant I had a working mental model before touching Baile’s data model.

publicPermission defaults to .none and it will confuse everyone

When you create a CKShare and send the invite link, CloudKit’s default publicPermission is .none. Anyone who taps the link gets “Item Unavailable. Owner stopped sharing or account does not have permission.” The fix is one line, but you have to know to add it:

share.publicPermission = .readWrite

Set that before saving the share. Not obvious from the docs.

SwiftUI apps need a scene delegate for share acceptance, not an app delegate

In a SwiftUI WindowGroup app, the share acceptance callback fires on UIWindowSceneDelegate, not your app delegate. The symptom: user taps Accept, app opens, nothing happens. You need both:

// App delegate — wire up the scene configuration
  func application(_ application: UIApplication,                                                                                                                                                               
      configurationForConnecting session: UISceneSession,                                                                                                                                                      
      options: UIScene.ConnectionOptions) -> UISceneConfiguration {
      let config = UISceneConfiguration(name: nil, sessionRole: session.role)                                                                                                                                  
      config.delegateClass = YourSceneDelegate.self                                                                                                                                                            
      return config
  }                                                                                                                                                                                                            
                                                                                
  // Scene delegate — this is where the callback actually fires                                                                                                                                                
  func windowScene(_ windowScene: UIWindowScene,
      userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {                                                                                                                                
      // accept the share here                                                                                                                                                                                 
  } 

If you only implement the app delegate version, it will never be called.

The WhatsApp share URL bug

Passing a plain String or URL to UIActivityViewController seems fine until you test on WhatsApp. The fix is UIActivityItemSource with a URL placeholder — iOS uses the placeholder type to decide which extensions to activate:

// Wrong — iOS advertises "text only", WhatsApp extension gets filtered out                                                                                                                                  
  UIActivityViewController(activityItems: [share.url!], ...)                    
                                                                                                                                                                                                               
  // Right — URL placeholder keeps WhatsApp, Telegram, Mail all available                                                                                                                                      
  class ShareItemSource: NSObject, UIActivityItemSource {                                                                                                                                                      
      func activityViewControllerPlaceholderItem(_ : UIActivityViewController) -> Any {                                                                                                                        
          return share.url! // must be URL, not String                          
      }                                                                                                                                                                                                        
      func activityViewController(_ controller: UIActivityViewController,       
          itemForActivityType type: UIActivity.ActivityType?) -> Any? {                                                                                                                                        
          if type == .mail || type == .message {                                                                                                                                                               
              return "Join my list: \(share.url!)" // readable text for Messages/Mail
          }                                                                                                                                                                                                    
          return share.url! // raw URL for WhatsApp, Telegram, etc.             
      }                                                                                                                                                                                                        
  }    

One afternoon and two iCloud accounts to find that.

Revoking and leaving are two different flows

Owner revokes access, participant leaves voluntarily — each one is a different state to clean up on both devices. Thinking through what “clean up” means in each scenario before writing the code saved me from some messy edge cases later.

Porting Back Was the Easy Part

Once ticklist had all six milestones solid, I had something better than documentation. Working code I understood completely, edge cases and all.

Bringing it into Baile wasn’t an experiment anymore. It was a copy-and-adapt exercise. I knew what to build, why each piece worked, and exactly where the traps were.

That’s the whole point of a PoC.

You Don’t Need to Be an Expert to Make Expert Decisions

I’m new to Swift. Baile is a four-week-old app with three users. None of that stopped the problem from being real, and none of it meant I had to stumble through it blindly in production code.

If a feature is unfamiliar and touches your core data model, the lean move is to pull it out, build the smallest possible version of the problem, and solve it there first.

It’s not slower. It’s the fastest path to getting it right.

If you are interested in trying Baile — The family home app, please drop a comment, I could send you an invite to join beta testing.