FriendController.swift (28239B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 5 /// Sibling of `ShareController`, but for *friend* zones rather than game 6 /// zones. A friendship is a durable, pairwise channel: one custom zone 7 /// (`friend-<pairKey>`) carrying a zone-wide `CKShare` with the other user 8 /// added as a `.readWrite` participant. The zone is created in the elected 9 /// owner's private database and accepted into the other user's shared 10 /// database; thereafter either side can write `.invite` `Ping`s into it. 11 /// 12 /// Bootstrap rides the *game* zone the two users already share: the owner 13 /// enqueues a `.friend` `Ping` whose `payload` carries the friend-zone share 14 /// URL. The other device applies it (`applyFriendPing`) and accepts the 15 /// share without any out-of-band link. 16 @MainActor 17 final class FriendController { 18 let container: CKContainer 19 private let persistence: PersistenceController 20 private let syncEngine: SyncEngine 21 private let syncMonitor: SyncMonitor? 22 private let eventLog: EventLog? 23 private let fetchAccountDecisionRecord: (CKRecord.ID) async throws -> CKRecord 24 25 init( 26 container: CKContainer, 27 persistence: PersistenceController, 28 syncEngine: SyncEngine, 29 syncMonitor: SyncMonitor? = nil, 30 eventLog: EventLog? = nil, 31 fetchAccountDecisionRecord: ((CKRecord.ID) async throws -> CKRecord)? = nil 32 ) { 33 self.container = container 34 self.persistence = persistence 35 self.syncEngine = syncEngine 36 self.syncMonitor = syncMonitor 37 self.eventLog = eventLog 38 self.fetchAccountDecisionRecord = fetchAccountDecisionRecord 39 ?? { try await container.privateCloudDatabase.record(for: $0) } 40 } 41 42 enum FriendError: Error { 43 case invalidShareRecord 44 case missingShareURL 45 case participantNotFound 46 case missingShareURLInPayload 47 case friendNotFound 48 case friendBlocked 49 case payloadEncodingFailed 50 } 51 52 // MARK: - Owner side 53 54 /// Creates the friendship if this device is the elected owner and no 55 /// friendship for the pair exists yet. No-ops for the non-owner (it 56 /// waits for the `.friend` Ping) and for an already-established pair. 57 /// `viaGameID` is the shared game whose zone carries the bootstrap Ping. 58 func establishIfOwner( 59 localAuthorID: String, 60 remoteAuthorID: String, 61 localDisplayName: String?, 62 viaGameID: UUID 63 ) async { 64 guard !remoteAuthorID.isEmpty, 65 localAuthorID != remoteAuthorID, 66 FriendZone.isOwner(localAuthorID: localAuthorID, remoteAuthorID: remoteAuthorID) 67 else { return } 68 69 let pairKey = FriendZone.pairKey(localAuthorID, remoteAuthorID) 70 if friendExists(pairKey: pairKey) { return } 71 72 syncMonitor?.recordStart("establish friendship") 73 do { 74 let zoneName = FriendZone.zoneName(pairKey: pairKey) 75 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) 76 77 // Every field of the local friendship is deterministic or already 78 // in hand, so any owner-side device can record it without reading 79 // anything back from the share. 80 let recordLocalFriendship = { 81 self.persistFriend( 82 authorID: remoteAuthorID, 83 pairKey: pairKey, 84 zoneName: zoneName, 85 zoneOwnerName: CKCurrentUserDefaultName, 86 databaseScope: 0 87 ) 88 } 89 90 try await createZone(zoneID) 91 92 // A sibling device on this same iCloud account may have already 93 // created the zone-wide share. `FriendEntity` is local-only, so 94 // the `friendExists` check above can't see its work and we still 95 // reach here. If the share exists we adopt the friendship locally 96 // *without* re-creating the share or re-enqueuing the `.friend` 97 // Ping — the sibling already delivered it to the friend. 98 if try await existingZoneWideShare(zoneID: zoneID) != nil { 99 recordLocalFriendship() 100 await seedOwnNameDecision( 101 localAuthorID: localAuthorID, 102 localDisplayName: localDisplayName, 103 zoneID: zoneID, 104 scope: 0 105 ) 106 syncMonitor?.recordSuccess("establish friendship") 107 return 108 } 109 110 let share: CKShare 111 do { 112 share = try await saveZoneWideShare( 113 zoneID: zoneID, 114 addingParticipant: remoteAuthorID 115 ) 116 } catch let error as CKError where error.code == .serverRecordChanged { 117 // Lost the create race to a sibling between the check above 118 // and this save. The share now exists; adopt it as above. 119 recordLocalFriendship() 120 await seedOwnNameDecision( 121 localAuthorID: localAuthorID, 122 localDisplayName: localDisplayName, 123 zoneID: zoneID, 124 scope: 0 125 ) 126 syncMonitor?.recordSuccess("establish friendship") 127 return 128 } 129 guard let url = share.url else { throw FriendError.missingShareURL } 130 131 recordLocalFriendship() 132 await seedOwnNameDecision( 133 localAuthorID: localAuthorID, 134 localDisplayName: localDisplayName, 135 zoneID: zoneID, 136 scope: 0 137 ) 138 139 let payload = FriendZone.BootstrapPayload( 140 friendShareURL: url.absoluteString, 141 pairKey: pairKey, 142 ownerAuthorID: localAuthorID 143 ) 144 await syncEngine.enqueuePing( 145 kind: .friend, 146 gameID: viaGameID, 147 authorID: localAuthorID, 148 playerName: localDisplayName ?? "", 149 payload: payload.encodedString() 150 ) 151 syncMonitor?.recordSuccess("establish friendship") 152 } catch { 153 syncMonitor?.recordError("establish friendship", error) 154 } 155 } 156 157 /// Writes the local user's current name into a just-recorded friend zone 158 /// as a `name` Decision, at the current (un-bumped) generation: a seed is 159 /// "the name as of this friendship", never a rename, so it must lose to 160 /// any real rename racing it. This is how a friend made *after* the last 161 /// rename learns the name — the rename fan-out only reaches zones that 162 /// existed at the time. 163 private func seedOwnNameDecision( 164 localAuthorID: String, 165 localDisplayName: String?, 166 zoneID: CKRecordZone.ID, 167 scope: Int16 168 ) async { 169 let name = (localDisplayName ?? "") 170 .trimmingCharacters(in: .whitespacesAndNewlines) 171 guard !name.isEmpty else { return } 172 await syncEngine.enqueueNameDecision( 173 authorID: localAuthorID, 174 name: name, 175 version: NameVersionStore.current(authorID: localAuthorID), 176 zoneID: zoneID, 177 scope: scope 178 ) 179 } 180 181 // MARK: - Participant side 182 183 /// Handles an inbound `.friend` Ping: accepts the friend-zone share and 184 /// records the friendship. Idempotent — a duplicate Ping for an 185 /// already-established pair is dropped. `localAuthorID`/`localDisplayName` 186 /// let the acceptor seed its own name Decision into the just-joined zone 187 /// so the owner learns this side's name without waiting for a rename. 188 func applyFriendPing( 189 _ ping: Ping, 190 localAuthorID: String?, 191 localDisplayName: String? 192 ) async { 193 guard ping.kind == .friend, 194 let payload = FriendZone.BootstrapPayload.decode(ping.payload) 195 else { return } 196 guard FriendZone.canAcceptBootstrap(payload, localAuthorID: localAuthorID) else { return } 197 if friendExists(pairKey: payload.pairKey) { return } 198 guard let url = URL(string: payload.friendShareURL) else { return } 199 200 syncMonitor?.recordStart("accept friendship") 201 do { 202 let metadata = try await fetchShareMetadata(url: url) 203 try await accept(metadata) 204 let zoneID = metadata.share.recordID.zoneID 205 persistFriend( 206 authorID: payload.ownerAuthorID, 207 pairKey: payload.pairKey, 208 zoneName: zoneID.zoneName, 209 zoneOwnerName: zoneID.ownerName, 210 databaseScope: 1 211 ) 212 if let localAuthorID, !localAuthorID.isEmpty { 213 await seedOwnNameDecision( 214 localAuthorID: localAuthorID, 215 localDisplayName: localDisplayName, 216 zoneID: zoneID, 217 scope: 1 218 ) 219 } 220 syncMonitor?.recordSuccess("accept friendship") 221 // `applyFriendPing` runs inside the `onPings` CKSyncEngine 222 // delegate callback. Awaiting a call back into CKSyncEngine from 223 // there trips its serialization guard and crashes 224 // (CKSyncEngine.swift:293: "Cannot await a call into CKSyncEngine 225 // from within a delegate callback"). Detach the post-accept 226 // refresh so the delegate callback returns first; it only needs 227 // to land eventually. 228 // Detached (not a plain `Task {}`): a `Task {}` here inherits this 229 // @MainActor context and can run `fetchChanges()` at one of the 230 // delegate callback's own suspension points — before `handleEvent` 231 // returns — so CKSyncEngine's guard still trips. A detached task 232 // runs off this actor, after the callback unwinds. (`Task {}` was 233 // the original, insufficient fix; the trap recurred in 2026.310.) 234 Task.detached { [syncEngine, syncMonitor] in 235 await syncMonitor?.run("friendship accept fetch") { 236 try await syncEngine.fetchChanges() 237 } 238 } 239 } catch { 240 syncMonitor?.recordError("accept friendship", error) 241 } 242 } 243 244 // MARK: - Re-invite 245 246 /// Writes an `.invite` Ping carrying the game's share URL into the friend 247 /// zone. The friend must already be added as a participant on the game's 248 /// `CKShare` (the caller does that via `ShareController` and passes the 249 /// resulting URL in). No-ops for an unknown or blocked friend. The optional 250 /// `gridSilhouette` is a `GridSilhouette`-encoded segment that lets the 251 /// recipient's "Invited" row preview the puzzle's shape. 252 func sendInvite( 253 toFriendAuthorID friendAuthorID: String, 254 gameID: UUID, 255 gameTitle: String, 256 inviterAuthorID: String, 257 inviterName: String, 258 gameShareURL: URL, 259 gridSilhouette: String? = nil, 260 puzzleSource: String? = nil 261 ) async throws { 262 let ctx = persistence.viewContext 263 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 264 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 265 req.fetchLimit = 1 266 guard let friend = try ctx.fetch(req).first else { 267 throw FriendError.friendNotFound 268 } 269 guard !friend.isBlocked else { throw FriendError.friendBlocked } 270 guard let zoneName = friend.friendZoneName, 271 let ownerName = friend.friendZoneOwnerName 272 else { throw FriendError.friendNotFound } 273 274 let payload = FriendZone.InvitePayload( 275 gameShareURL: gameShareURL.absoluteString, 276 gridSilhouette: gridSilhouette, 277 puzzleSource: puzzleSource 278 ) 279 guard let encoded = payload.encodedString() else { 280 throw FriendError.payloadEncodingFailed 281 } 282 283 await syncEngine.enqueueFriendZonePing( 284 kind: .invite, 285 gameID: gameID, 286 gameTitle: gameTitle, 287 authorID: inviterAuthorID, 288 playerName: inviterName, 289 addressee: friendAuthorID, 290 friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), 291 friendZoneScope: friend.databaseScope, 292 payload: encoded 293 ) 294 } 295 296 /// Writes a `.decline` Ping into the friend zone telling the inviter we 297 /// turned down their game invite, so their device frees our seat on the 298 /// game's `CKShare` and surfaces a banner. Mirrors `sendInvite` reversed: 299 /// `declinerAuthorID` is us (the sender), `inviterAuthorID` the addressee. 300 /// Carries no payload — `(gameID, declinerAuthorID)` fully identify the seat 301 /// to free. No-ops for an unknown or blocked friend. 302 func sendDecline( 303 toInviterAuthorID inviterAuthorID: String, 304 gameID: UUID, 305 gameTitle: String, 306 declinerAuthorID: String, 307 declinerName: String 308 ) async throws { 309 let ctx = persistence.viewContext 310 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 311 req.predicate = NSPredicate(format: "authorID == %@", inviterAuthorID) 312 req.fetchLimit = 1 313 guard let friend = try ctx.fetch(req).first else { 314 throw FriendError.friendNotFound 315 } 316 guard !friend.isBlocked else { throw FriendError.friendBlocked } 317 guard let zoneName = friend.friendZoneName, 318 let ownerName = friend.friendZoneOwnerName 319 else { throw FriendError.friendNotFound } 320 321 await syncEngine.enqueueFriendZonePing( 322 kind: .decline, 323 gameID: gameID, 324 gameTitle: gameTitle, 325 authorID: declinerAuthorID, 326 playerName: declinerName, 327 addressee: inviterAuthorID, 328 friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), 329 friendZoneScope: friend.databaseScope 330 ) 331 } 332 333 /// Consume-deletes a directed Ping from the pairwise friend zone — an 334 /// `.invite` once it has been accepted or found stale, or a `.decline` once 335 /// the addressed inviter has freed the seat. Removing the source record 336 /// stops it re-creating on the recipient's devices and withdraws any banner 337 /// a sibling showed. `friendAuthorID` is the *other* party on the zone (the 338 /// inviter for an invite we consume, the decliner for a decline we consume). 339 func deleteFriendZonePing(fromFriendAuthorID friendAuthorID: String, recordName: String) async { 340 let ctx = persistence.viewContext 341 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 342 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 343 req.fetchLimit = 1 344 guard let friend = try? ctx.fetch(req).first, 345 let zoneName = friend.friendZoneName, 346 let ownerName = friend.friendZoneOwnerName 347 else { return } 348 349 await syncEngine.deletePing( 350 recordName: recordName, 351 zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), 352 databaseScope: friend.databaseScope 353 ) 354 } 355 356 // MARK: - Block 357 358 /// Marks the friend blocked and tears down the channel so nothing further 359 /// can arrive from them: the owner deletes the friend zone outright; a 360 /// participant leaves by deleting the zone-wide share. The `FriendEntity` 361 /// row is kept (as a blocked tombstone) so future `.invite` Pings are 362 /// suppressed and the zone stays out of `knownZones`. 363 func blockAndTeardown(friendAuthorID: String) async throws { 364 let ctx = persistence.viewContext 365 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 366 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 367 req.fetchLimit = 1 368 guard let friend = try ctx.fetch(req).first else { return } 369 let scope = friend.databaseScope 370 let zoneName = friend.friendZoneName 371 let ownerName = friend.friendZoneOwnerName 372 friend.isBlocked = true 373 try ctx.save() 374 375 // Make the block authoritative across the user's own devices. Written 376 // before the channel teardown so the durable fact survives even a 377 // partial teardown; `applyDecisionRecord` projects it onto a blocked 378 // `FriendEntity` on every other device. 379 await syncEngine.enqueueDecision(kind: "block", key: friendAuthorID) 380 381 guard let zoneName, let ownerName else { return } 382 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) 383 syncMonitor?.recordStart("block friend") 384 do { 385 if scope == 0 { 386 // We own the friend zone — deleting it revokes the share for 387 // both sides. 388 try await deleteZone(zoneID, in: container.privateCloudDatabase) 389 } else { 390 // Participant — leave by deleting the zone-wide share record. 391 let shareID = CKRecord.ID( 392 recordName: CKRecordNameZoneWideShare, 393 zoneID: zoneID 394 ) 395 do { 396 try await container.sharedCloudDatabase.deleteRecord(withID: shareID) 397 } catch let error as CKError 398 where error.code == .unknownItem || error.code == .zoneNotFound { 399 // Already gone — nothing to do. 400 } 401 } 402 syncMonitor?.recordSuccess("block friend") 403 } catch { 404 syncMonitor?.recordError("block friend", error) 405 } 406 } 407 408 // MARK: - Rename 409 410 /// Sets (or, with an empty string, clears) the user's private nickname 411 /// for a friend. The nickname lives on the local `FriendEntity` and is 412 /// made authoritative across the user's own devices via a versioned 413 /// `nickname` Decision in the account zone — the same channel `block` 414 /// rides; it is never written into the friend zone, so the friend never 415 /// sees it. Each rename bumps the per-friend generation so the newest 416 /// rename wins any cross-device race (`applyDecisionRecord`). 417 func setNickname(friendAuthorID: String, nickname: String) async throws { 418 let ctx = persistence.viewContext 419 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 420 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 421 req.fetchLimit = 1 422 guard let friend = try ctx.fetch(req).first else { 423 throw FriendError.friendNotFound 424 } 425 let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) 426 let version = friend.nicknameVersion + 1 427 friend.nickname = trimmed.isEmpty ? nil : trimmed 428 friend.nicknameVersion = version 429 try ctx.save() 430 FriendEntity.rebuildNicknameDirectory(in: ctx) 431 // An empty payload propagates the clear: the Decision record stays 432 // (preserving the version) but applies as "no nickname". 433 await syncEngine.enqueueDecision( 434 kind: RecordSerializer.nicknameDecisionKind, 435 key: friendAuthorID, 436 payload: trimmed.isEmpty ? nil : trimmed, 437 version: version 438 ) 439 } 440 441 // MARK: - CloudKit helpers 442 443 private func deleteZone( 444 _ zoneID: CKRecordZone.ID, 445 in database: CKDatabase 446 ) async throws { 447 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 448 let op = CKModifyRecordZonesOperation( 449 recordZonesToSave: nil, 450 recordZoneIDsToDelete: [zoneID] 451 ) 452 op.qualityOfService = .userInitiated 453 op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } 454 database.add(op) 455 } 456 } 457 458 private func createZone(_ zoneID: CKRecordZone.ID) async throws { 459 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 460 let op = CKModifyRecordZonesOperation( 461 recordZonesToSave: [CKRecordZone(zoneID: zoneID)], 462 recordZoneIDsToDelete: nil 463 ) 464 op.qualityOfService = .userInitiated 465 op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } 466 self.container.privateCloudDatabase.add(op) 467 } 468 } 469 470 private func saveZoneWideShare( 471 zoneID: CKRecordZone.ID, 472 addingParticipant remoteAuthorID: String 473 ) async throws -> CKShare { 474 let share = CKShare(recordZoneID: zoneID) 475 share.publicPermission = .none 476 let participant = try await fetchParticipant(forUserRecordName: remoteAuthorID) 477 participant.permission = .readWrite 478 share.addParticipant(participant) 479 let saved = try await container.privateCloudDatabase.save(share) 480 guard let savedShare = saved as? CKShare else { 481 throw FriendError.invalidShareRecord 482 } 483 return savedShare 484 } 485 486 /// The zone-wide `CKShare` for `zoneID` in our private database, or `nil` 487 /// if it doesn't exist yet. Lets an owner-side device detect a friend zone 488 /// a sibling device on the same iCloud account already shared. 489 private func existingZoneWideShare( 490 zoneID: CKRecordZone.ID 491 ) async throws -> CKShare? { 492 let shareID = CKRecord.ID( 493 recordName: CKRecordNameZoneWideShare, 494 zoneID: zoneID 495 ) 496 do { 497 let record = try await container.privateCloudDatabase.record(for: shareID) 498 return record as? CKShare 499 } catch let error as CKError 500 where error.code == .unknownItem || error.code == .zoneNotFound { 501 return nil 502 } 503 } 504 505 private func fetchParticipant( 506 forUserRecordName recordName: String 507 ) async throws -> CKShare.Participant { 508 let lookup = CKUserIdentity.LookupInfo( 509 userRecordID: CKRecord.ID(recordName: recordName) 510 ) 511 return try await withCheckedThrowingContinuation { cont in 512 var found: CKShare.Participant? 513 let op = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookup]) 514 op.perShareParticipantResultBlock = { _, result in 515 if case .success(let participant) = result { found = participant } 516 } 517 op.fetchShareParticipantsResultBlock = { result in 518 switch result { 519 case .success: 520 if let found { 521 cont.resume(returning: found) 522 } else { 523 cont.resume(throwing: FriendError.participantNotFound) 524 } 525 case .failure(let error): 526 cont.resume(throwing: error) 527 } 528 } 529 self.container.add(op) 530 } 531 } 532 533 private func fetchShareMetadata(url: URL) async throws -> CKShare.Metadata { 534 try await withCheckedThrowingContinuation { cont in 535 var metadata: CKShare.Metadata? 536 let op = CKFetchShareMetadataOperation(shareURLs: [url]) 537 op.shouldFetchRootRecord = false 538 op.perShareMetadataResultBlock = { _, result in 539 if case .success(let m) = result { metadata = m } 540 } 541 op.fetchShareMetadataResultBlock = { result in 542 switch result { 543 case .success: 544 if let metadata { 545 cont.resume(returning: metadata) 546 } else { 547 cont.resume(throwing: FriendError.invalidShareRecord) 548 } 549 case .failure(let error): 550 cont.resume(throwing: error) 551 } 552 } 553 self.container.add(op) 554 } 555 } 556 557 private func accept(_ metadata: CKShare.Metadata) async throws { 558 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 559 let op = CKAcceptSharesOperation(shareMetadatas: [metadata]) 560 op.acceptSharesResultBlock = { result in cont.resume(with: result) } 561 self.container.add(op) 562 } 563 } 564 565 // MARK: - Core Data 566 567 /// True if *any* `FriendEntity` for the pair exists — including a blocked 568 /// one. This is deliberate for v1: a blocked friendship is permanent, so 569 /// `establishIfOwner` / `applyFriendPing` short-circuit here and never 570 /// re-bootstrap a channel the user has torn down. Re-friending a blocked 571 /// collaborator is intentionally out of scope; lifting it later means 572 /// adding an explicit unblock path, not changing this guard. 573 private func friendExists(pairKey: String) -> Bool { 574 let ctx = persistence.viewContext 575 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 576 req.predicate = NSPredicate(format: "pairKey == %@", pairKey) 577 req.fetchLimit = 1 578 return ((try? ctx.count(for: req)) ?? 0) > 0 579 } 580 581 /// Records the friendship row. The display name is deliberately *not* 582 /// written here — it arrives exclusively via the friend's `name` Decision 583 /// (`RecordSerializer.applyDecisionRecord`); until that syncs, the invite 584 /// surfaces fall back to the freshest per-game Player snapshot, then 585 /// "Player". 586 private func persistFriend( 587 authorID: String, 588 pairKey: String, 589 zoneName: String, 590 zoneOwnerName: String, 591 databaseScope: Int16 592 ) { 593 let ctx = persistence.viewContext 594 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 595 req.predicate = NSPredicate(format: "pairKey == %@", pairKey) 596 req.fetchLimit = 1 597 let entity = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) 598 entity.authorID = authorID 599 entity.pairKey = pairKey 600 entity.friendZoneName = zoneName 601 entity.friendZoneOwnerName = zoneOwnerName 602 entity.databaseScope = databaseScope 603 if entity.createdAt == nil { entity.createdAt = Date() } 604 do { 605 try ctx.save() 606 Task { [weak self] in 607 await self?.applyAccountNicknameDecisionIfPresent(for: authorID) 608 } 609 } catch { 610 eventLog?.note("FriendController: persistFriend save failed — \(error)", level: "error") 611 } 612 } 613 614 /// A sibling can set a nickname before this device has bootstrapped the 615 /// `FriendEntity`. CKSyncEngine will then advance past the account-zone 616 /// Decision without applying it because there is no friend row yet. After 617 /// bootstrap creates the row, fetch the deterministic Decision directly 618 /// and replay it once. 619 func applyAccountNicknameDecisionIfPresent(for friendAuthorID: String) async { 620 let recordName = RecordSerializer.decisionRecordName( 621 kind: RecordSerializer.nicknameDecisionKind, 622 key: friendAuthorID 623 ) 624 let recordID = CKRecord.ID( 625 recordName: recordName, 626 zoneID: RecordSerializer.accountZoneID 627 ) 628 do { 629 let record = try await fetchAccountDecisionRecord(recordID) 630 let ctx = persistence.viewContext 631 let wrote = RecordSerializer.applyDecisionRecord( 632 record, 633 to: ctx, 634 localAuthorID: nil, 635 databaseScope: 0 636 ) 637 if wrote { 638 try ctx.save() 639 FriendEntity.rebuildNicknameDirectory(in: ctx) 640 eventLog?.note( 641 "FriendController: replayed nickname decision \(recordName)", 642 level: "info" 643 ) 644 } 645 } catch let error as CKError 646 where error.code == .unknownItem || error.code == .zoneNotFound { 647 // Most friendships will not have a private nickname yet. 648 } catch { 649 eventLog?.note( 650 "FriendController: nickname decision replay failed for \(friendAuthorID) — \(error)", 651 level: "error" 652 ) 653 } 654 } 655 }