Comment fonctionne le partage du foyer sans faire confiance à un serveur
Deux comptes iCloud, un budget partagé, aucun backend sous mon contrôle. NSPersistentCloudKitContainer n'en était pas capable, alors j'ai écrit le moteur de synchronisation à la main. Voici ce que ça demande vraiment.
The Smart Budget permet à un foyer de partager un même budget. Deux personnes, deux comptes iCloud distincts, qui modifient toutes deux les mêmes transactions et les mêmes budgets en temps réel. La contrainte que je me suis fixée : aucun serveur sous mon contrôle au milieu. Vos données financières se synchronisent directement via CloudKit d'Apple, de compte à compte. Je ne les vois jamais, et il n'y a aucun backend qui pourrait fuiter, être saisi par la justice ou piraté.
Apple propose un framework qui semble faire exactement ça — NSPersistentCloudKitContainer avec CKShare. J'ai passé trois jours à essayer de le faire fonctionner. Puis je l'ai supprimé et j'ai écrit le moteur de synchronisation moi-même. Voici pourquoi, et à quoi ressemble la solution de remplacement, avec le vrai code.
L'approche évidente, et le crash
La voie officielle : adosser Core Data à NSPersistentCloudKitContainer (NSPCKC), qui reproduit automatiquement votre store dans une base de données CloudKit privée, puis appeler share(...) pour créer un CKShare pour les objets à partager.
Ça crashe. Pas tout de suite — c'est le côté cruel. Ça marche dans la démo et ça meurt dans le cas réel et désordonné, quand votre CKShare personnalisé atterrit dans la même base privée que le framework est occupé à reproduire. Voici le commentaire qui se trouve aujourd'hui là où vivait NSPCKC :
/// Apple's `NSPersistentCloudKitContainer` is gone. It crashed when our custom `CKShare`
/// landed in the same private DB it was mirroring (it tries to ingest the share as a
/// `cloudkit.share` Core Data entity that doesn't exist in the model). Replacing it with
/// our own bare-CloudKit pipeline removes the framework collision and gives us one path
/// for all sync, single-user and household.
Le framework voit un enregistrement de type cloudkit.share dans la base qu'il reproduit, et tente de l'ingérer comme s'il s'agissait de l'une de vos entités Core Data. Il n'y a aucune entité cloudkit.share dans votre modèle, puisque c'est un type système de CloudKit. Boum.
Et le problème de fond n'était pas ce crash-là — c'était que je n'avais aucun moyen de le corriger. Extrait du document qui planifiait la réécriture :
Trois jours de tests de
NSPersistentCloudKitContainer+CKSharepour le partage du foyer ont fait apparaître un schéma récurrent : chaque correctif révèle un nouveau mode de défaillance (blocages « zone introuvable », boucles « Can't find metadata », doublons dus au timing des cascades, réinitialisations CKKS en état partiel impossibles à récupérer). La reproduction automatique du framework est opaque — quand elle déraille, nous n'avons aucune API pour l'inspecter ou la corriger ; seule l'option « tout purger et recommencer » fonctionne.
Voilà le vrai réquisitoire. Quand le mécanisme automatique fonctionne, c'est magique. Quand il ne fonctionne pas, il n'y a aucune prise par laquelle mettre les mains dedans. Pour une fonctionnalité où « recommencer » veut dire « supprimer le budget partagé d'une famille », c'est rédhibitoire. Le résumé, dans le document de conception, du compromis que j'ai fait : « échanger la complexité de se battre contre le framework contre la complexité du contrôle explicite ». Je choisirai le contrôle explicite à chaque fois qu'il s'agit de l'argent de mes utilisateurs.
Le remplacement : deux modes, un seul moteur
J'ai remplacé NSPCKC par un pipeline CloudKit brut : je gère moi-même l'envoi, la récupération, la résolution des conflits et la traduction des enregistrements. La synchronisation personnelle sur un seul compte et le partage du foyer passent par le même moteur — ils ne diffèrent que par la zone et la base CloudKit qu'ils visent. Le cœur est un actor :
/// Custom CloudKit sync layer for ALL of the user's shareable data.
/// **Two modes, one engine:**
/// - **Personal mode** (default): syncs `Shared.sqlite` to a `personal-zone` in the
/// user's private CloudKit DB. No `CKShare`.
/// - **Household mode**: syncs to a `household-shared` zone in the owner's private DB,
/// with a `CKShare` attached.
/// **Conflict resolution:** last-writer-wins by `lastModified`.
actor HouseholdSync {
static let shared = HouseholdSync()
Toute l'astuce du « pas de serveur central » réside dans la façon dont un participant adresse les données. Il y a quatre modes, et chacun choisit sa zone et sa base :
enum Mode { case off; case personal; case householdOwner; case householdParticipant }
private var currentZoneID: CKRecordZone.ID? {
switch currentMode {
case .personal:
return CKRecordZone.ID(zoneName: Self.personalZoneName, ownerName: CKCurrentUserDefaultName)
case .householdOwner:
return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: CKCurrentUserDefaultName)
case .householdParticipant:
let ownerName = UserDefaults.standard.string(forKey: Self.householdOwnerCKUserIDKey) ?? CKCurrentUserDefaultName
return CKRecordZone.ID(zoneName: Self.householdZoneName, ownerName: ownerName)
default: return nil
}
}
private var currentDatabase: CKDatabase? {
switch currentMode {
case .personal, .householdOwner: return container.privateCloudDatabase
case .householdParticipant: return container.sharedCloudDatabase
default: return nil
}
}
Le propriétaire écrit dans une zone household-shared de sa propre base privée. Le participant lit et écrit cette même zone via sa base partagée, en reconstituant le couple (zoneName, ownerName) du propriétaire à partir de l'identifiant utilisateur CloudKit du propriétaire, enregistré au moment où il a accepté l'invitation. Deux bases privées, cousues ensemble par un seul partage. Les serveurs d'Apple font la synchronisation ; le serveur de personne ne fait la logique.
Quatre pièces alimentent ce moteur.
LocalChangeWatcher — transformer les sauvegardes en opérations d'envoi
Un observateur de NSManagedObjectContextDidSave. Chaque fois que le store partagé enregistre, il construit des opérations d'envoi pour les objets insérés, modifiés ou supprimés, et lance un envoi. Le détail subtil, celui qui porte tout, c'est ce qu'il ignore :
private func handle(_ notification: Notification) {
guard SyncStatus.shared.isActive else { return }
guard let sharedStore = controller.sharedStore else { return }
// Skip saves emitted by HouseholdSync itself (pull's apply + push's metadata writeback)
if let savingContext = notification.object as? NSManagedObjectContext,
Self.skipContexts.contains(ObjectIdentifier(savingContext)) {
return
}
for object in inserted.union(updated) {
guard object.objectID.persistentStore == sharedStore else { continue }
guard RecordTranslator.shareableEntityNames.contains(object.entity.name ?? "") else { continue }
ops.append(.init(cloudKitRecordName: recordName, entityName: entityName,
kind: .upsert, objectIDURI: object.objectID.uriRepresentation().absoluteString))
}
Task {
await PushQueue.shared.enqueue(ops)
await HouseholdSync.shared.pushPendingChanges()
}
}
Cet ensemble skipContexts est indexé par ObjectIdentifier, délibérément — lire context.transactionAuthor depuis une autre file peut provoquer un interblocage de Core Data. Sans ce filtre, appliquer une modification récupérée déclencherait elle-même une sauvegarde, qui la renverrait aussitôt, que le partenaire récupérerait et réenregistrerait… ce qui nous amène au plus beau bug du projet (plus bas).
PushQueue — une file FIFO durable qui se fusionne d'elle-même
Le réseau coupe. Les apps sont tuées. La file des envois en attente est donc un actor adossé à un fichier, qui s'écrit sur disque de façon atomique à chaque modification, et qui dédoublonne à l'entrée :
func enqueue(_ ops: [Operation]) async {
for op in ops {
// URI-based dedupe FIRST so a stale optimistic-UUID op for the same
// managed object gets dropped.
if let uri = op.objectIDURI {
queue.removeAll { $0.objectIDURI == uri || $0.cloudKitRecordName == op.cloudKitRecordName }
} else {
queue.removeAll { $0.cloudKitRecordName == op.cloudKitRecordName }
}
queue.append(op)
}
await persist()
}
La charge utile n'est pas sérialisée — l'envoi relit l'état actuel de l'objet géré au moment de l'envoi. Si vous modifiez une transaction cinq fois en deux secondes, ces modifications se réduisent donc gratuitement à un seul envoi de l'état final.
RecordTranslator — Core Data ⇄ CKRecord, dans les deux sens
Des fonctions pures, sans E/S, exécutées à l'intérieur du context.perform de l'appelant. Chaque objet partageable porte un cloudKitRecordName stable, pour que les deux appareils s'accordent sur une identité unique par ligne logique. Dans le sens sortant, il n'encode que les relations to-one sous forme de références, et ignore celles dont la cible n'a pas encore été envoyée :
for (name, rel) in object.entity.relationshipsByName where !rel.isToMany {
guard let target = object.value(forKey: name) as? NSManagedObject else { record[name] = nil; continue }
guard let targetName = target.value(forKey: "cloudKitRecordName") as? String, !targetName.isEmpty else {
continue // target not pushed yet — next cycle fixes it
}
let targetID = CKRecord.ID(recordName: targetName, zoneID: zoneID)
record[name] = CKRecord.Reference(recordID: targetID, action: .none)
}
Dans le sens entrant, il y a une garde qui ressemble à du pinaillage et qui est en réalité tout l'enjeu :
let target = resolveReference(ref, destinationEntity: rel.destinationEntity!.name!, in: context)
if target != nil {
object.setValue(target, forKey: name)
}
// else: target not pulled to this context yet — leave as-is. Critically we do NOT
// overwrite to nil and create orphan rows that dedupBudgets would group under a
// single nil-category bucket, summing 9 different-category Budgets into one row.
Si une transaction arrive avant la catégorie vers laquelle elle pointe, il ne faut surtout pas mettre la relation à nil. Faites-le, et vous créez des orphelins — que la passe de dédoublonnage (juste après) se fera un plaisir de fusionner. C'est exactement comme ça que j'ai un jour produit une seule ligne de budget de 6,800 AED à partir de neuf catégories sans rapport.
Les parties difficiles
HouseholdDedup — converger sans coordinateur
Quand deux téléphones sont hors ligne et créent tous deux une catégorie « Courses », vous obtenez des doublons une fois qu'ils se synchronisent. Il n'y a pas de serveur pour arbitrer. La solution, c'est la convergence : les deux appareils exécutent la même passe déterministe sur les mêmes données et produisent le même ensemble de suppressions, qui se propage ensuite par le pipeline d'envoi normal. Aucune coordination nécessaire.
/// **Determinism:** both devices run dedup independently. As long as both pick the same
/// canonical for each group (we use lexicographically-smallest `cloudKitRecordName`,
/// preferring `isSystem` rows for Categories), they produce the same delete set →
/// converges to the same final state under LWW.
Avant de supprimer un doublon, il redirige chaque relation vers le survivant et met à jour lastModified, pour que la fusion l'emporte en last-writer-wins (le dernier à écrire gagne) sur l'appareil du partenaire :
let canonical = pickCanonicalCategory(from: dupes)
for dupe in dupes where dupe.objectID != canonical.objectID {
if let txns = dupe.transactions as? Set<Transaction> {
for txn in txns { txn.category = canonical; txn.lastModified = now }
}
// budgets, splits, aliases, recurringSeries likewise…
context.delete(dupe)
}
L'incident des 6,800 AED m'a appris à être paranoïaque sur la clé de regroupement. Une première version regroupait les budgets sans catégorie sous la chaîne littérale "nil" et fusionnait neuf catégories différentes en une seule ligne géante. Désormais, elle ignore complètement les budgets sans catégorie, et le traducteur (plus haut) refuse de créer ces nil au départ. Deux gardes indépendantes pour un seul bug, parce que ce bug se manifeste par un chiffre faux dans le budget de quelqu'un, et qu'il n'existe pas de pire bug dans une app de finances qu'un chiffre faux affiché avec assurance.
HouseholdShareAcceptance — le point d'entrée des invitations que SwiftUI vous cache
Quand votre partenaire touche le lien de partage, iOS doit transmettre à votre app les CKShare.Metadata. Ça passe par un callback de UIWindowSceneDelegate — qu'une app en pur SwiftUI n'a pas. Vous en fournissez donc un via UIApplicationDelegateAdaptor :
func windowScene(
_ windowScene: UIWindowScene,
userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata
) {
let metadata = cloudKitShareMetadata
Task {
do { try await HouseholdSync.shared.acceptShare(metadata: metadata) }
catch { /* … */ }
}
}
Le même délégué gère les réveils par notification push silencieuse : une CKNotification validée déclenche une récupération puis un envoi, et — un détail qui vous fera gagner des heures — il signale toujours .newData à la fin. Signalez .failed, et iOS commence à brider vos push en arrière-plan ; tout à coup, la synchronisation « en temps réel » a dix minutes de retard.
HouseholdIdentity — savoir qui vous êtes, de façon synchrone
La moitié de la logique doit répondre à « lesquels de ces enregistrements sont à moi ? » sans aller-retour réseau. L'identifiant utilisateur CloudKit de l'utilisateur actuel est donc résolu une fois et mis en cache dans UserDefaults :
func refresh() async {
do {
let recordID = try await CKContainer(identifier: PersistenceController.cloudKitContainerID).userRecordID()
currentUserID = recordID.recordName
await runClaimSweepIfNeeded()
} catch {
Self.logger.info("Could not resolve CloudKit user ID (likely no iCloud account)…")
}
}
Cet identifiant en cache estampille ownerCloudKitUserID et loggedByMemberID sur les enregistrements : c'est ainsi que « Quitter le foyer » sait quelles lignes chaque personne conserve, et que le rapport de dépenses par membre attribue un café au bon partenaire.
Créer et accepter le partage
Côté propriétaire : créer la zone, puis créer un seul CKShare couvrant toute la zone (et non un par enregistrement), changer de rôle, réinitialiser le jeton de modifications, remettre tout en file pour que tout se déverse dans la nouvelle zone, et démonter la zone personnelle devenue obsolète :
_ = try await privateDB.save(CKRecordZone(zoneID: householdZoneID))
let share = CKShare(recordZoneID: householdZoneID)
share[CKShare.SystemFieldKey.title] = "The Smart Budget — Household" as CKRecordValue
share.publicPermission = .none
_ = try await privateDB.save(share)
setRole(.owner)
UserDefaults.standard.removeObject(forKey: Self.serverChangeTokenKey)
await PushQueue.shared.clear()
await reenqueueAllLocalRecords(controller: controller)
await deletePersonalZoneServerSide()
Vous présentez ce CKShare brut avec le UICloudSharingController natif d'Apple — vous n'avez pas besoin de NSPCKC pour piloter l'interface d'invitation du système, contrairement à ce que tout le monde suppose et qui semble vous lier au framework. Côté participant, on accepte et on enregistre l'identifiant du propriétaire :
_ = try await container.accept(metadata)
// Owner's CKUserID is encoded in the share's zoneID — persist it so currentZoneID
// can construct the right (zoneName, ownerName) tuple on subsequent pushes/pulls.
let ownerName = metadata.share.recordID.zoneID.ownerName
UserDefaults.standard.set(ownerName, forKey: Self.householdOwnerCKUserIDKey)
setRole(.participant)
Sur l'ensemble, la résolution des conflits se fait en last-writer-wins sur un horodatage lastModified — la version locale ne l'emporte que si elle est strictement plus récente, sinon la version distante s'applique. C'est simple, et ça converge.
Deux histoires de guerre dont le code se souvient encore
La tempête d'envois iCloud incontrôlée (corrigée le 29 juin 2026). L'ensemble skipContexts (celui de LocalChangeWatcher) était vidé sur la mauvaise file par rapport au bloc de l'observateur. Le vidage perdait systématiquement la course, si bien que chaque réécriture de métadonnées repartait comme un nouvel envoi — que le partenaire récupérait et renvoyait — : une boucle de synchronisation infinie qui martelait iCloud. Le correctif tenait en une ligne, sur le moment où l'ensemble à ignorer est vidé. Les boucles de rétroaction dans le code de synchronisation ne s'annoncent pas ; elles font juste fondre en silence votre budget de débit.
La garde contre les récupérations périmées. Une récupération photographie le mode et la zone en cours avant de démarrer. Si acceptShare ou disconnect change le mode en plein vol, les résultats de la récupération sont écartés plutôt que mal interprétés — parce qu'une erreur « zone disparue » pendant une récupération lancée dans l'ancien mode n'a pas le même sens que « votre partenaire a mis fin au partage », et que la traiter comme telle a un jour envoyé les données d'un conjoint dans la mauvaise zone.
En ferais-je un SDK ?
Il y a un package CloudKitHouseholdSync bien propre caché là-dedans — HouseholdSync, PushQueue, RecordTranslator, LocalChangeWatcher, le dédoublonnage, le point d'entrée de scène. On me le demande souvent. Ma réponse honnête : pas encore. Il intègre des hypothèses propres à Smart Budget (la clé de dédoublonnage sur le nom en minuscules, l'ordre des dépendances entre entités) qu'il faudrait généraliser avec soin, et je ne veux pas publier une bibliothèque de synchronisation avant qu'elle n'ait fait ses preuves auprès de vrais foyers en production. Le plan d'extraction est écrit. Il sera publié quand l'outil l'aura mérité.
La leçon n'est pas « n'utilisez jamais NSPersistentCloudKitContainer » — pour une app mono-utilisateur qui veut simplement une sauvegarde iCloud, il est excellent, et vous devriez l'utiliser. C'est plutôt ceci : dès que vous mélangez un CKShare personnalisé à une base privée reproduite par Core Data, vous avez quitté la route goudronnée, et le framework ne vous donne aucun outil pour survivre hors piste. Posséder le pipeline, c'est plus de code. C'est aussi la seule version où, quand quelque chose se passe mal avec l'argent partagé d'une famille, j'ai une prise pour intervenir et corriger — au lieu de « tout purger et recommencer ».
The Smart Budget est disponible dès maintenant sur iPhone. Le partage du foyer est une fonctionnalité Pro ; le moteur de synchronisation personnelle qui se trouve dessous est le même code, et il tourne pour tout le monde. Rien ne transite par un serveur sous mon contrôle.
— Omar