Part 7: CloudKit
I've put it off for as long as I can but here's the boring post on adding CloudKit.
The first thing we need to do is add some capabilities to the apps and widget extensions. We'll make them all part of the same app group, and then add background modes for push notifications, which CloudKit needs to keep our database up to date.
As I've mentioned before, it could be that SwiftData has added simpler ways to do these things but this is essentially documenting what I did earlier this year with CloudKit as the easiest way to share data between the widgets on all the platforms.
App Group
First we need to add all 5 targets (two apps, 3 widgets) to the same AppGroup. I started with the main app but you can pick any target to be first.
We're going to select the target and the signing and capabilities target and you should see the app groups entitlement we added before. If we click the (+) we can add a new app group. I tend to use reverse DNS and since this is across multiple platforms I use .apps.
between the domain name and the app name. Note that it has to start with group.
If you start with the main app make sure you see the app group in both the macOS and the iOS/tvOS sections.
Once that's done we can go to each of the other four targets and just enable that new group by checking the apparently-disabled-but-not-really checkbox.
An odd note. First time I did this all was well. Second time I found I hit a problem with the Mac Widget extension and somehow I couldn't access app groups in the UI. If this happens you can edit any of the other entitlements files as source and copy and paste the app groups section into the Mac widget entitlements. Its the same text everywhere.
Next we're going to go back to each target in turn and add the "iCloud" entitlement.
We'll choose CloudKit, and add a new container in one of the targets, and then select that container in all the other targets.
Now all the apps and widgets will talk to iCloud and all will share data.
I committed here to keep things tidy, and so the diff is more obvious about what each step does. Its "Part 7: Data Sharing Entitlements"
Background Modes
CloudKit works using silent push notifications to keep our data up to date, so we need to enable that for the two app targets (not the widgets). Add the Background Modes entitlement to the main app (twice, once for iOS, once for tvOS) and then again to the Watch app target.
In both targets we need to check Remote Notifications (twice in the main app, once in the watch app).
There's another commit here - "Part 7: Background Modes"
That's all the configuration. Now we need some more code.
CoreData and CloudKit
I shuffled the project structure around a little. I made a "new group WITHOUT folder" called Apps and one called Widgets just to pull the various folders together. Make sure its WITHOUT folder for this step otherwise the various paths we've set on the build settings stop working.
Drag the three widgets and the widget shared to the Widgets group. Drag the two apps and the app shared to the Apps group.
Next I make a new group WITH a folder called Model. This will hold all our persistence code that works across widgets and apps.
I hate this with-or-without-folder stuff as sometimes the specific path matters (like the entitlements) and sometimes it doesn't. As a rule I try and have most things in folders that match the group structure and only use folderless groups at the very top level for collecting stuff together.
The first thing we're going to add is a CoreData model, and we'll add it to all targets. I've called it JustOneThingCloudModel
which is a bit verbose but I want to clearly distinguish the CoreData persistent model entities from any ViewModel type classes or structs (like Thing
). Maybe SwiftData
will make this unnecessary but right now CoreData model properties all have to be nullable and @objc
etc so lets leave the CoreData machinery isolated from our SwiftUI+Swift models.
In that model I've created one entity, PersistentThing
, and added properties for the text
and createdAt
(just like the current Swift model) and a UUID
for uniqueness. I don't know for sure that I need that but old habits die hard and I was taught to give my database rows an explicit primary key.
One last step in this little journey and that's to add some way for the apps and widgets to access the CoreData stack.
When you tick the CoreData+CloudKit box on creating a new project, you get a file called Persistence.swift
that looks mostly like this. (it also has some stuff for a preview, but I've ignored that for now).
I've made a few changes to this as you'll see. Some were required to used App Groups, then I discovered a problem with paths on tvOS and then I've done some refactoring just to make it clearer (imo) which bits are which.
First here's my version of what's above. Like the original we override the URL if we're in memory. Unlike the original we also have to override the URL even in the default case because we are using App Groups.
The default, not-ideal error handling is just extracted to a separate method
And I added an extension on URL
to build the store URL for the group. The key is FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
. You can also see here the change I had to make when I discovered that tvOS didn't work.
I also pulled out some of the static convenience code into an extension.
All this code is in a file called PersistenceController.swift
in the Model group.
The last thing we need to do here is add some code to initialise the Persistence controller in the App and Widgets and check it all build and runs.
All this is in another commit "Part 7: Simple PersistenceController"