FriendController.swift (41874B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 5 /// Sibling of `ShareController`, but for *friend* mailboxes rather than game 6 /// zones. A friendship is a durable, pairwise channel built from two mailbox 7 /// zones, both named `friend-<pairKey>` and distinguished by owner: each user 8 /// owns one (their *inbox*, in their private database, carrying a zone-wide 9 /// `CKShare` with the friend as a `.readWrite` participant) and joins the 10 /// other's (their *outbox*, in their shared database). You write to a friend by 11 /// writing into their inbox (your outbox); you receive by reading your own 12 /// inbox. Blocking downgrades the friend to `.readOnly` on the inbox you own — 13 /// server-enforced and reversible by upgrading back, with no teardown. 14 /// 15 /// Bootstrap rides the *game* zone the two users already share and is 16 /// symmetric: each side ensures its own inbox exists and enqueues a `.friend` 17 /// `Ping` whose `payload` carries that inbox's share URL. The other device 18 /// applies it (`applyFriendPing`) and accepts the share — gaining write access 19 /// to that inbox — without any out-of-band link. 20 @MainActor 21 final class FriendController { 22 /// Mints (if needed) and publishes this device's per-pair invite encryption 23 /// key into the friend zone so the pair can seal invite pushes. Supplied by 24 /// `AppServices`; nil where push isn't wired (e.g. tests). 25 typealias FriendInvitationKeyPublisher = 26 (_ pairKey: String, _ friendZoneID: CKRecordZone.ID, _ friendZoneScope: DatabaseScope) async -> Void 27 28 let container: CKContainer 29 private let persistence: PersistenceController 30 private let syncEngine: SyncEngine 31 private let syncMonitor: SyncMonitor? 32 private let eventLog: EventLog? 33 private let fetchAccountDecisionRecord: (CKRecord.ID) async throws -> CKRecord 34 private let publishFriendInvitationKey: FriendInvitationKeyPublisher? 35 36 init( 37 container: CKContainer, 38 persistence: PersistenceController, 39 syncEngine: SyncEngine, 40 syncMonitor: SyncMonitor? = nil, 41 eventLog: EventLog? = nil, 42 publishFriendInvitationKey: FriendInvitationKeyPublisher? = nil, 43 fetchAccountDecisionRecord: ((CKRecord.ID) async throws -> CKRecord)? = nil 44 ) { 45 self.container = container 46 self.persistence = persistence 47 self.syncEngine = syncEngine 48 self.syncMonitor = syncMonitor 49 self.eventLog = eventLog 50 self.publishFriendInvitationKey = publishFriendInvitationKey 51 self.fetchAccountDecisionRecord = fetchAccountDecisionRecord 52 ?? { try await container.privateCloudDatabase.record(for: $0) } 53 } 54 55 enum FriendError: Error { 56 case invalidShareRecord 57 case missingShareURL 58 case participantNotFound 59 case missingShareURLInPayload 60 case friendNotFound 61 case friendBlocked 62 case friendshipNotReady 63 case payloadEncodingFailed 64 } 65 66 // MARK: - Bootstrap 67 68 /// Ensures this device's *inbox* mailbox for the pair exists and is shared 69 /// to the friend, then enqueues a `.friend` Ping carrying the inbox share 70 /// URL so the friend can accept it and gain write access. Symmetric: both 71 /// sides run this for the pair — there is no elected owner. Idempotent — a 72 /// no-op once this side's inbox is established (the friend's own 73 /// `applyFriendPing` records their half independently). `viaGameID` is the 74 /// shared game whose zone carries the bootstrap Ping. The local display name 75 /// is *not* seeded here: it is written into our outbox by `applyFriendPing`, 76 /// which is the first moment we have write access to it. 77 func establish( 78 localAuthorID: String, 79 remoteAuthorID: String, 80 localDisplayName: String?, 81 viaGameID: UUID 82 ) async { 83 guard !remoteAuthorID.isEmpty, 84 localAuthorID != remoteAuthorID 85 else { return } 86 87 let pairKey = FriendZone.pairKey(localAuthorID, remoteAuthorID) 88 if FriendZone.inboxEstablished(pairKey: pairKey) { return } 89 let zoneID = FriendZone.inboxZoneID(pairKey: pairKey) 90 91 syncMonitor?.recordStart("establish friendship") 92 do { 93 try await createZone(zoneID) 94 95 // A sibling device on this same iCloud account may have already 96 // created the inbox share; the per-device marker can't see its 97 // work. If the share exists we adopt locally *without* re-creating 98 // it or re-enqueuing the `.friend` Ping — already delivered. 99 if try await existingZoneWideShare(zoneID: zoneID) != nil { 100 persistFriend(authorID: remoteAuthorID, pairKey: pairKey) 101 FriendZone.markInboxEstablished(pairKey: pairKey) 102 syncMonitor?.recordSuccess("establish friendship") 103 return 104 } 105 106 let share: CKShare 107 do { 108 share = try await saveZoneWideShare( 109 zoneID: zoneID, 110 addingParticipant: remoteAuthorID 111 ) 112 } catch let error as CKError where error.code == .serverRecordChanged { 113 // Lost the create race to a sibling between the check above and 114 // this save. The share now exists; adopt it without re-sending. 115 persistFriend(authorID: remoteAuthorID, pairKey: pairKey) 116 FriendZone.markInboxEstablished(pairKey: pairKey) 117 syncMonitor?.recordSuccess("establish friendship") 118 return 119 } 120 guard let url = share.url else { throw FriendError.missingShareURL } 121 122 persistFriend(authorID: remoteAuthorID, pairKey: pairKey) 123 FriendZone.markInboxEstablished(pairKey: pairKey) 124 125 let payload = FriendZone.BootstrapPayload( 126 friendShareURL: url.absoluteString, 127 pairKey: pairKey, 128 ownerAuthorID: localAuthorID 129 ) 130 await syncEngine.enqueuePing( 131 kind: .friend, 132 gameID: viaGameID, 133 authorID: localAuthorID, 134 playerName: localDisplayName ?? "", 135 payload: payload.encodedString() 136 ) 137 syncMonitor?.recordSuccess("establish friendship") 138 } catch { 139 syncMonitor?.recordError("establish friendship", error) 140 } 141 } 142 143 /// Writes the local user's current name into a just-recorded friend zone 144 /// as a `name` Decision, at the current (un-bumped) generation: a seed is 145 /// "the name as of this friendship", never a rename, so it must lose to 146 /// any real rename racing it. This is how a friend made *after* the last 147 /// rename learns the name — the rename fan-out only reaches zones that 148 /// existed at the time. 149 private func seedOwnNameDecision( 150 localAuthorID: String, 151 localDisplayName: String?, 152 zoneID: CKRecordZone.ID 153 ) async { 154 let name = (localDisplayName ?? "") 155 .trimmingCharacters(in: .whitespacesAndNewlines) 156 guard !name.isEmpty else { return } 157 await syncEngine.enqueueNameDecision( 158 authorID: localAuthorID, 159 name: name, 160 version: NameVersionStore.current(authorID: localAuthorID), 161 zoneID: zoneID, 162 // The seed is only ever written into the friend's inbox (our 163 // outbox), a zone the friend owns. 164 scope: .shared 165 ) 166 } 167 168 // MARK: - Participant side 169 170 /// Handles an inbound `.friend` Ping: accepts the friend-zone share and 171 /// records the friendship. Idempotent — a duplicate Ping for an 172 /// already-established pair is dropped. `localAuthorID`/`localDisplayName` 173 /// let the acceptor seed its own name Decision into the just-joined zone 174 /// so the owner learns this side's name without waiting for a rename. 175 func applyFriendPing( 176 _ ping: Ping, 177 localAuthorID: String?, 178 localDisplayName: String? 179 ) async { 180 guard ping.kind == .friend, 181 let payload = FriendZone.BootstrapPayload.decode(ping.payload) 182 else { return } 183 guard FriendZone.canAcceptBootstrap(payload, localAuthorID: localAuthorID) else { return } 184 // Gate on whether we've accepted *their* inbox share, not on whether a 185 // friendship row exists: our own `establish` may have created the row 186 // already, but we still owe the accept that gives us write access to 187 // their inbox (our outbox). 188 if FriendZone.outboxAccepted(pairKey: payload.pairKey) { return } 189 guard let url = URL(string: payload.friendShareURL) else { return } 190 191 syncMonitor?.recordStart("accept friendship") 192 do { 193 let metadata = try await fetchShareMetadata(url: url) 194 // The accepted zone is the friend's inbox — our outbox. Its owner 195 // name is the friend's CloudKit user-record name. 196 let outboxZoneID = metadata.share.recordID.zoneID 197 // Both mailbox zones are deterministically named `friend-<pairKey>`, 198 // so a legitimate outbox always matches. The share URL and the zone 199 // it targets are attacker-controllable: a co-player can mint a share 200 // on a zone he owns but *names* for another pair, and `payload.pairKey` 201 // is already pinned to `ownerAuthorID` by `canAcceptBootstrap`. Reject 202 // unless the accepted zone's name matches that pairKey, so a forged 203 // share can't be bound to this friendship and used to flow forged 204 // Decision/Ping records under a borrowed identity. Validate before 205 // accepting so we never take on a mis-named zone at all. 206 guard outboxZoneID.zoneName == FriendZone.zoneName(pairKey: payload.pairKey) else { 207 syncMonitor?.note("accept friendship rejected: accepted zone name does not match pair key") 208 return 209 } 210 // The name check alone is spoofable: a co-player can *name* his own 211 // zone for the (us, Carol) pair and claim `ownerAuthorID: Carol` — 212 // the pairKey then pins to Carol and both checks above pass, binding 213 // Carol's friendship to a zone the forger owns (leaking our name/ 214 // invite key to him, letting his zone authenticate `.decline` Pings 215 // as Carol, and blocking the real Carol's bootstrap behind 216 // `outboxAccepted`). The zone's `ownerName` is CloudKit-authoritative 217 // — it is the actual owner's user-record name, which a legitimate 218 // outbox's owner always is — so also require it to be the claimed 219 // friend. (Never `CKCurrentUserDefaultName` here: this is a fetched 220 // share we don't own, and `canAcceptBootstrap` already rejects 221 // self-pairs.) 222 guard outboxZoneID.ownerName == payload.ownerAuthorID else { 223 syncMonitor?.note("accept friendship rejected: accepted zone owner does not match claimed friend") 224 return 225 } 226 try await accept(metadata) 227 FriendZone.markOutboxAccepted(pairKey: payload.pairKey) 228 persistFriend(authorID: payload.ownerAuthorID, pairKey: payload.pairKey) 229 if let localAuthorID, !localAuthorID.isEmpty { 230 // Tell the friend our name by writing into their inbox (our 231 // outbox) — the first moment we have write access to it. 232 await seedOwnNameDecision( 233 localAuthorID: localAuthorID, 234 localDisplayName: localDisplayName, 235 zoneID: outboxZoneID 236 ) 237 } 238 // Publish our per-pair invite encryption key into the outbox so the 239 // friend can decrypt invite pushes we seal; self-heals on the next 240 // push-registration refresh if this misses. 241 Task { [weak self] in 242 await self?.publishFriendInvitationKey?(payload.pairKey, outboxZoneID, .shared) 243 } 244 syncMonitor?.recordSuccess("accept friendship") 245 // `applyFriendPing` runs inside the `onPings` CKSyncEngine 246 // delegate callback. Awaiting a call back into CKSyncEngine from 247 // there trips its serialization guard and crashes 248 // (CKSyncEngine.swift:293: "Cannot await a call into CKSyncEngine 249 // from within a delegate callback"). Detach the post-accept 250 // refresh so the delegate callback returns first; it only needs 251 // to land eventually. 252 // Detached (not a plain `Task {}`): a `Task {}` here inherits this 253 // @MainActor context and can run `fetchChanges()` at one of the 254 // delegate callback's own suspension points — before `handleEvent` 255 // returns — so CKSyncEngine's guard still trips. A detached task 256 // runs off this actor, after the callback unwinds. (`Task {}` was 257 // the original, insufficient fix; the trap recurred in 2026.310.) 258 Task.detached { [syncEngine, syncMonitor] in 259 await syncMonitor?.run("friendship accept fetch") { 260 try await syncEngine.fetchChanges() 261 } 262 } 263 } catch { 264 syncMonitor?.recordError("accept friendship", error) 265 } 266 } 267 268 // MARK: - Bootstrap heal 269 270 /// Launch-time repair for half-established friendships. The `.friend` 271 /// bootstrap Ping is delivered exactly once, via the carrier game zone's 272 /// delta fetch; if the recipient's accept fails at that moment nothing 273 /// ever re-sends it — `establish` is marker-gated and its adopt paths 274 /// assume a sibling already delivered — so the pair is stuck: the friend 275 /// never gains write access to our inbox and their `inviteFriend` throws 276 /// `friendshipNotReady` forever. 277 /// 278 /// The inbox share's participant list is server truth for whether the 279 /// friend got in. For each unblocked friend still not `.accepted` there, 280 /// re-enqueue the bootstrap Ping through a game they collaborate on 281 /// (re-adding the participant first if the share lost them). Healthy 282 /// pairs cost one share fetch; a per-pair cooldown bounds Ping accrual 283 /// while a friend's devices stay dormant. This heals the *friend's* 284 /// missing acceptance — our own missing acceptance of their inbox is 285 /// healed by the same pass running on their devices. 286 func healPendingBootstraps(localAuthorID: String, localDisplayName: String?) async { 287 guard !localAuthorID.isEmpty else { return } 288 289 struct PendingPair { 290 let friendAuthorID: String 291 let pairKey: String 292 let viaGameID: UUID? 293 } 294 let ctx = persistence.viewContext 295 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 296 req.predicate = NSPredicate(format: "isBlocked == NO") 297 let pairs: [PendingPair] = ((try? ctx.fetch(req)) ?? []).compactMap { friend in 298 guard let authorID = friend.authorID, !authorID.isEmpty, 299 let pairKey = friend.pairKey 300 else { return nil } 301 return PendingPair( 302 friendAuthorID: authorID, 303 pairKey: pairKey, 304 viaGameID: Self.bootstrapCarrierGameID(friendAuthorID: authorID, in: ctx) 305 ) 306 } 307 guard !pairs.isEmpty else { return } 308 309 var healthy = 0, announced = 0, skipped = 0 310 for pair in pairs { 311 do { 312 guard var share = try await existingZoneWideShare( 313 zoneID: FriendZone.inboxZoneID(pairKey: pair.pairKey) 314 ) else { 315 // Nothing to announce: the inbox share was never created. 316 // `establish` owns that repair. 317 skipped += 1 318 continue 319 } 320 let participant = share.participants.first { 321 $0.userIdentity.userRecordID?.recordName == pair.friendAuthorID 322 } 323 if participant?.acceptanceStatus == .accepted { 324 healthy += 1 325 continue 326 } 327 guard FriendZone.canAttemptBootstrapHeal(pairKey: pair.pairKey) else { 328 skipped += 1 329 continue 330 } 331 guard let viaGameID = pair.viaGameID else { 332 // No live carrier zone reaches this friend; a future 333 // shared game re-runs `establish` via reconcile anyway. 334 skipped += 1 335 continue 336 } 337 if participant == nil || participant?.acceptanceStatus == .removed { 338 // The friend can only accept a zone-wide share they are 339 // invited to (publicPermission is .none), so restore the 340 // participant before re-announcing. 341 let restored = try await fetchParticipant( 342 forUserRecordName: pair.friendAuthorID 343 ) 344 restored.permission = .readWrite 345 share.addParticipant(restored) 346 guard let saved = try await container.privateCloudDatabase 347 .save(share) as? CKShare 348 else { throw FriendError.invalidShareRecord } 349 share = saved 350 } 351 guard let url = share.url else { throw FriendError.missingShareURL } 352 FriendZone.markBootstrapHealAttempted(pairKey: pair.pairKey) 353 let payload = FriendZone.BootstrapPayload( 354 friendShareURL: url.absoluteString, 355 pairKey: pair.pairKey, 356 ownerAuthorID: localAuthorID 357 ) 358 await syncEngine.enqueuePing( 359 kind: .friend, 360 gameID: viaGameID, 361 authorID: localAuthorID, 362 playerName: localDisplayName ?? "", 363 payload: payload.encodedString() 364 ) 365 announced += 1 366 syncMonitor?.note( 367 "friendship heal: re-announced inbox share to " + 368 "\(pair.friendAuthorID.prefix(8))… via game " + 369 "\(viaGameID.uuidString.prefix(8))" 370 ) 371 } catch { 372 skipped += 1 373 syncMonitor?.note( 374 "friendship heal: check failed for " + 375 "\(pair.friendAuthorID.prefix(8))… — \(error.localizedDescription)" 376 ) 377 } 378 } 379 syncMonitor?.note( 380 "friendship heal: pairs=\(pairs.count) healthy=\(healthy) " + 381 "announced=\(announced) skipped=\(skipped)" 382 ) 383 } 384 385 /// A collaborative game the friend is known to play, used as the carrier 386 /// zone for a bootstrap re-announcement — their devices sync it, so the 387 /// Ping reaches them. The most recently updated game is the liveliest 388 /// channel. 389 private static func bootstrapCarrierGameID( 390 friendAuthorID: String, 391 in ctx: NSManagedObjectContext 392 ) -> UUID? { 393 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 394 req.predicate = NSPredicate( 395 format: "authorID == %@ AND game.isAccessRevoked == NO AND " + 396 "(game.databaseScope == 1 OR game.ckShareRecordName != nil)", 397 friendAuthorID 398 ) 399 req.sortDescriptors = [NSSortDescriptor(key: "game.updatedAt", ascending: false)] 400 req.fetchLimit = 1 401 return (try? ctx.fetch(req).first)?.game?.id 402 } 403 404 // MARK: - Re-invite 405 406 /// Writes an `.invite` Ping carrying the game's share URL into the friend 407 /// zone. The friend must already be added as a participant on the game's 408 /// `CKShare` (the caller does that via `ShareController` and passes the 409 /// resulting URL in). No-ops for an unknown or blocked friend. The optional 410 /// `gridSilhouette` is a `GridSilhouette`-encoded segment that lets the 411 /// recipient's "Invited" row preview the puzzle's shape. 412 func sendInvite( 413 toFriendAuthorID friendAuthorID: String, 414 gameID: UUID, 415 gameTitle: String, 416 inviterAuthorID: String, 417 inviterName: String, 418 gameShareURL: URL, 419 gridSilhouette: String? = nil, 420 puzzleSource: String? = nil, 421 notification: String? = nil, 422 rollbackParticipantOnFailure: Bool = false 423 ) async throws { 424 let ctx = persistence.viewContext 425 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 426 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 427 req.fetchLimit = 1 428 guard let friend = try ctx.fetch(req).first else { 429 throw FriendError.friendNotFound 430 } 431 guard !friend.isBlocked else { throw FriendError.friendBlocked } 432 guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } 433 try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: friendAuthorID) 434 435 let payload = FriendZone.InvitePayload( 436 gameShareURL: gameShareURL.absoluteString, 437 gridSilhouette: gridSilhouette, 438 puzzleSource: puzzleSource, 439 notification: notification 440 ) 441 guard let encoded = payload.encodedString() else { 442 throw FriendError.payloadEncodingFailed 443 } 444 445 try await syncEngine.enqueueFriendZonePing( 446 kind: .invite, 447 gameID: gameID, 448 gameTitle: gameTitle, 449 authorID: inviterAuthorID, 450 playerName: inviterName, 451 addressee: friendAuthorID, 452 friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), 453 friendZoneScope: .shared, 454 payload: encoded, 455 rollbackParticipantOnFailure: rollbackParticipantOnFailure, 456 waitForServerConfirmation: true 457 ) 458 } 459 460 /// Writes a `.decline` Ping into the friend zone telling the inviter we 461 /// turned down their game invite, so their device frees our seat on the 462 /// game's `CKShare` and surfaces a banner. Mirrors `sendInvite` reversed: 463 /// `declinerAuthorID` is us (the sender), `inviterAuthorID` the addressee. 464 /// Carries no payload — `(gameID, declinerAuthorID)` fully identify the seat 465 /// to free. No-ops for an unknown or blocked friend. 466 func sendDecline( 467 toInviterAuthorID inviterAuthorID: String, 468 gameID: UUID, 469 gameTitle: String, 470 declinerAuthorID: String, 471 declinerName: String 472 ) async throws { 473 let ctx = persistence.viewContext 474 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 475 req.predicate = NSPredicate(format: "authorID == %@", inviterAuthorID) 476 req.fetchLimit = 1 477 guard let friend = try ctx.fetch(req).first else { 478 throw FriendError.friendNotFound 479 } 480 guard !friend.isBlocked else { throw FriendError.friendBlocked } 481 guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } 482 try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: inviterAuthorID) 483 484 try await syncEngine.enqueueFriendZonePing( 485 kind: .decline, 486 gameID: gameID, 487 gameTitle: gameTitle, 488 authorID: declinerAuthorID, 489 playerName: declinerName, 490 addressee: inviterAuthorID, 491 friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: inviterAuthorID), 492 friendZoneScope: .shared 493 ) 494 } 495 496 /// Consume-deletes a directed Ping from the pairwise friend zone — an 497 /// `.invite` once it has been accepted or found stale, or a `.decline` once 498 /// the addressed inviter has freed the seat. Removing the source record 499 /// stops it re-creating on the recipient's devices and withdraws any banner 500 /// a sibling showed. `friendAuthorID` is the *other* party on the zone (the 501 /// inviter for an invite we consume, the decliner for a decline we consume). 502 func deleteFriendZonePing(fromFriendAuthorID friendAuthorID: String, recordName: String) async { 503 let ctx = persistence.viewContext 504 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 505 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 506 req.fetchLimit = 1 507 guard let friend = try? ctx.fetch(req).first, 508 let pairKey = friend.pairKey 509 else { return } 510 511 // A consumed Ping is always one we *received* — an `.invite` we were 512 // sent, or a `.decline` sent to us — so it lives in our own inbox (the 513 // zone we own, private engine). 514 await syncEngine.deletePing( 515 recordName: recordName, 516 zoneID: FriendZone.inboxZoneID(pairKey: pairKey), 517 databaseScope: .private 518 ) 519 } 520 521 // MARK: - Block / Unblock 522 523 /// Blocks a friend by downgrading them to `.readOnly` on the inbox we own, 524 /// so they can no longer write invites / name / key records to us. This is 525 /// server-enforced and inherently account-wide (one share), so every device 526 /// on this account gets it for free; it is fully reversible by `unblock`, 527 /// which upgrades the same participant back to `.readWrite`. The zone and 528 /// share are left intact — nothing is torn down. The versioned `block` 529 /// Decision converges the UI flag across our own devices. 530 func block(friendAuthorID: String) async throws { 531 try await setBlocked(friendAuthorID: friendAuthorID, blocked: true) 532 } 533 534 /// Reverses `block`: upgrades the friend back to `.readWrite` on our inbox 535 /// and clears the local/UI flag. No carrier and no re-acceptance — the 536 /// channel was never torn down. 537 func unblock(friendAuthorID: String) async throws { 538 try await setBlocked(friendAuthorID: friendAuthorID, blocked: false) 539 } 540 541 private func setBlocked(friendAuthorID: String, blocked: Bool) async throws { 542 let ctx = persistence.viewContext 543 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 544 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 545 req.fetchLimit = 1 546 guard let friend = try ctx.fetch(req).first, 547 let pairKey = friend.pairKey 548 else { return } 549 let version = friend.blockVersion + 1 550 551 let label = blocked ? "block friend" : "unblock friend" 552 syncMonitor?.recordStart(label) 553 do { 554 try await setInboxParticipantPermission( 555 friendAuthorID: friendAuthorID, 556 pairKey: pairKey, 557 permission: blocked ? .readOnly : .readWrite 558 ) 559 friend.isBlocked = blocked 560 friend.blockVersion = version 561 try ctx.save() 562 563 // Converge the UI flag across our own devices. Enforcement itself is 564 // the server-side share permission change above, which is 565 // account-wide, so this Decision only needs to carry the flag 566 // (versioned, so a stale copy can't resurrect a cleared block). 567 await syncEngine.enqueueDecision( 568 kind: RecordSerializer.blockDecisionKind, 569 key: friendAuthorID, 570 payload: blocked ? "1" : "0", 571 version: version 572 ) 573 syncMonitor?.recordSuccess(label) 574 } catch { 575 syncMonitor?.recordError(label, error) 576 throw error 577 } 578 } 579 580 /// Fetches the zone-wide `CKShare` on our inbox and sets the friend 581 /// participant's permission, then saves. Fetch-then-save uses the server's 582 /// current change tag so the permission write doesn't lose a tag race. 583 private func setInboxParticipantPermission( 584 friendAuthorID: String, 585 pairKey: String, 586 permission: CKShare.ParticipantPermission 587 ) async throws { 588 let zoneID = FriendZone.inboxZoneID(pairKey: pairKey) 589 let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID) 590 let database = container.privateCloudDatabase 591 guard let share = try await database.record(for: shareID) as? CKShare else { 592 throw FriendError.invalidShareRecord 593 } 594 guard let participant = share.participants.first(where: { 595 $0.userIdentity.userRecordID?.recordName == friendAuthorID 596 }) else { 597 throw FriendError.participantNotFound 598 } 599 participant.permission = permission 600 _ = try await database.save(share) 601 } 602 603 /// Ensures this device has accepted the friend's inbox share before trying 604 /// to write a Ping/Decision to it. The UserDefaults marker is the fast path; 605 /// a direct shared-zone probe covers restored devices or marker loss after 606 /// CloudKit has already delivered the accepted zone. 607 func ensureOutboxReady(friendAuthorID: String) async throws { 608 let ctx = persistence.viewContext 609 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 610 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 611 req.fetchLimit = 1 612 guard let friend = try ctx.fetch(req).first else { 613 throw FriendError.friendNotFound 614 } 615 guard !friend.isBlocked else { throw FriendError.friendBlocked } 616 guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } 617 try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: friendAuthorID) 618 } 619 620 private func ensureOutboxAccepted(pairKey: String, friendAuthorID: String) async throws { 621 if FriendZone.outboxAccepted(pairKey: pairKey) { return } 622 let zoneID = FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID) 623 if try await zoneExists(zoneID, in: container.sharedCloudDatabase) { 624 FriendZone.markOutboxAccepted(pairKey: pairKey) 625 return 626 } 627 throw FriendError.friendshipNotReady 628 } 629 630 // MARK: - Rename 631 632 /// Sets (or, with an empty string, clears) the user's private nickname 633 /// for a friend. The nickname lives on the local `FriendEntity` and is 634 /// made authoritative across the user's own devices via a versioned 635 /// `nickname` Decision in the account zone — the same channel `block` 636 /// rides; it is never written into the friend zone, so the friend never 637 /// sees it. Each rename bumps the per-friend generation so the newest 638 /// rename wins any cross-device race (`applyDecisionRecord`). 639 func setNickname(friendAuthorID: String, nickname: String) async throws { 640 let ctx = persistence.viewContext 641 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 642 req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) 643 req.fetchLimit = 1 644 guard let friend = try ctx.fetch(req).first else { 645 throw FriendError.friendNotFound 646 } 647 let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) 648 let version = friend.nicknameVersion + 1 649 friend.nickname = trimmed.isEmpty ? nil : trimmed 650 friend.nicknameVersion = version 651 try ctx.save() 652 FriendEntity.rebuildNicknameDirectory(in: ctx) 653 // An empty payload propagates the clear: the Decision record stays 654 // (preserving the version) but applies as "no nickname". 655 await syncEngine.enqueueDecision( 656 kind: RecordSerializer.nicknameDecisionKind, 657 key: friendAuthorID, 658 payload: trimmed.isEmpty ? nil : trimmed, 659 version: version 660 ) 661 } 662 663 // MARK: - CloudKit helpers 664 665 private func createZone(_ zoneID: CKRecordZone.ID) async throws { 666 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 667 let op = CKModifyRecordZonesOperation( 668 recordZonesToSave: [CKRecordZone(zoneID: zoneID)], 669 recordZoneIDsToDelete: nil 670 ) 671 op.qualityOfService = .userInitiated 672 op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } 673 self.container.privateCloudDatabase.add(op) 674 } 675 } 676 677 private func saveZoneWideShare( 678 zoneID: CKRecordZone.ID, 679 addingParticipant remoteAuthorID: String 680 ) async throws -> CKShare { 681 let share = CKShare(recordZoneID: zoneID) 682 share.publicPermission = .none 683 let participant = try await fetchParticipant(forUserRecordName: remoteAuthorID) 684 participant.permission = .readWrite 685 share.addParticipant(participant) 686 let saved = try await container.privateCloudDatabase.save(share) 687 guard let savedShare = saved as? CKShare else { 688 throw FriendError.invalidShareRecord 689 } 690 return savedShare 691 } 692 693 /// The zone-wide `CKShare` for `zoneID` in our private database, or `nil` 694 /// if it doesn't exist yet. Lets an owner-side device detect a friend zone 695 /// a sibling device on the same iCloud account already shared. 696 private func existingZoneWideShare( 697 zoneID: CKRecordZone.ID 698 ) async throws -> CKShare? { 699 let shareID = CKRecord.ID( 700 recordName: CKRecordNameZoneWideShare, 701 zoneID: zoneID 702 ) 703 do { 704 let record = try await container.privateCloudDatabase.record(for: shareID) 705 return record as? CKShare 706 } catch let error as CKError 707 where error.code == .unknownItem || error.code == .zoneNotFound { 708 return nil 709 } 710 } 711 712 private func zoneExists(_ zoneID: CKRecordZone.ID, in database: CKDatabase) async throws -> Bool { 713 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Bool, Error>) in 714 var exists = false 715 var perZoneError: Error? 716 let op = CKFetchRecordZonesOperation(recordZoneIDs: [zoneID]) 717 op.qualityOfService = .userInitiated 718 op.perRecordZoneResultBlock = { _, result in 719 switch result { 720 case .success: 721 exists = true 722 case .failure(let error as CKError) 723 where error.code == .zoneNotFound || error.code == .unknownItem: 724 exists = false 725 case .failure(let error): 726 perZoneError = error 727 } 728 } 729 op.fetchRecordZonesResultBlock = { result in 730 switch result { 731 case .success: 732 if let perZoneError { 733 cont.resume(throwing: perZoneError) 734 } else { 735 cont.resume(returning: exists) 736 } 737 case .failure(let error as CKError) 738 where error.code == .zoneNotFound || error.code == .unknownItem: 739 cont.resume(returning: false) 740 case .failure(let error): 741 cont.resume(throwing: error) 742 } 743 } 744 database.add(op) 745 } 746 } 747 748 private func fetchParticipant( 749 forUserRecordName recordName: String 750 ) async throws -> CKShare.Participant { 751 let lookup = CKUserIdentity.LookupInfo( 752 userRecordID: CKRecord.ID(recordName: recordName) 753 ) 754 return try await withCheckedThrowingContinuation { cont in 755 var found: CKShare.Participant? 756 let op = CKFetchShareParticipantsOperation(userIdentityLookupInfos: [lookup]) 757 op.perShareParticipantResultBlock = { _, result in 758 if case .success(let participant) = result { found = participant } 759 } 760 op.fetchShareParticipantsResultBlock = { result in 761 switch result { 762 case .success: 763 if let found { 764 cont.resume(returning: found) 765 } else { 766 cont.resume(throwing: FriendError.participantNotFound) 767 } 768 case .failure(let error): 769 cont.resume(throwing: error) 770 } 771 } 772 self.container.add(op) 773 } 774 } 775 776 private func fetchShareMetadata(url: URL) async throws -> CKShare.Metadata { 777 try await withCheckedThrowingContinuation { cont in 778 var metadata: CKShare.Metadata? 779 let op = CKFetchShareMetadataOperation(shareURLs: [url]) 780 op.shouldFetchRootRecord = false 781 op.perShareMetadataResultBlock = { _, result in 782 if case .success(let m) = result { metadata = m } 783 } 784 op.fetchShareMetadataResultBlock = { result in 785 switch result { 786 case .success: 787 if let metadata { 788 cont.resume(returning: metadata) 789 } else { 790 cont.resume(throwing: FriendError.invalidShareRecord) 791 } 792 case .failure(let error): 793 cont.resume(throwing: error) 794 } 795 } 796 self.container.add(op) 797 } 798 } 799 800 private func accept(_ metadata: CKShare.Metadata) async throws { 801 try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in 802 let op = CKAcceptSharesOperation(shareMetadatas: [metadata]) 803 op.acceptSharesResultBlock = { result in cont.resume(with: result) } 804 self.container.add(op) 805 } 806 } 807 808 // MARK: - Core Data 809 810 /// Records the friendship row. The display name is deliberately *not* 811 /// written here — it arrives exclusively via the friend's `name` Decision 812 /// (`RecordSerializer.applyDecisionRecord`); until that syncs, the invite 813 /// surfaces fall back to the freshest per-game Player snapshot, then 814 /// "Player". Both mailbox zones are derivable from `pairKey` + `authorID`, 815 /// so no zone fields are stored. The invite encryption key is published from 816 /// `applyFriendPing` instead — the first point we have outbox write access. 817 private func persistFriend(authorID: String, pairKey: String) { 818 let ctx = persistence.viewContext 819 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 820 req.predicate = NSPredicate(format: "pairKey == %@", pairKey) 821 req.fetchLimit = 1 822 let entity = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) 823 entity.authorID = authorID 824 entity.pairKey = pairKey 825 if entity.createdAt == nil { entity.createdAt = Date() } 826 do { 827 try ctx.save() 828 Task { [weak self] in 829 await self?.applyAccountNicknameDecisionIfPresent(for: authorID) 830 } 831 } catch { 832 eventLog?.note("FriendController: persistFriend save failed — \(error)", level: "error") 833 } 834 } 835 836 /// A sibling can set a nickname before this device has bootstrapped the 837 /// `FriendEntity`. CKSyncEngine will then advance past the account-zone 838 /// Decision without applying it because there is no friend row yet. After 839 /// bootstrap creates the row, fetch the deterministic Decision directly 840 /// and replay it once. 841 func applyAccountNicknameDecisionIfPresent(for friendAuthorID: String) async { 842 let recordName = RecordSerializer.decisionRecordName( 843 kind: RecordSerializer.nicknameDecisionKind, 844 key: friendAuthorID 845 ) 846 let recordID = CKRecord.ID( 847 recordName: recordName, 848 zoneID: RecordSerializer.accountZoneID 849 ) 850 do { 851 let record = try await fetchAccountDecisionRecord(recordID) 852 let ctx = persistence.viewContext 853 let wrote = RecordSerializer.applyDecisionRecord( 854 record, 855 to: ctx, 856 localAuthorID: nil, 857 databaseScope: .private 858 ) 859 if wrote { 860 try ctx.save() 861 FriendEntity.rebuildNicknameDirectory(in: ctx) 862 eventLog?.note( 863 "FriendController: replayed nickname decision \(recordName)", 864 level: "info" 865 ) 866 } 867 } catch let error as CKError 868 where error.code == .unknownItem || error.code == .zoneNotFound { 869 // Most friendships will not have a private nickname yet. 870 } catch { 871 eventLog?.note( 872 "FriendController: nickname decision replay failed for \(friendAuthorID) — \(error)", 873 level: "error" 874 ) 875 } 876 } 877 } 878 879 extension FriendController.FriendError: LocalizedError { 880 var errorDescription: String? { 881 switch self { 882 case .invalidShareRecord: 883 return "The crossmate record in iCloud is invalid." 884 case .missingShareURL, .missingShareURLInPayload: 885 return "The crossmate share link is missing." 886 case .participantNotFound: 887 return "That player could not be found in iCloud." 888 case .friendNotFound: 889 return "That Crossmate is not available yet." 890 case .friendBlocked: 891 return "That Crossmate is blocked." 892 case .friendshipNotReady: 893 return "This Crossmate is still syncing. Try again in a moment." 894 case .payloadEncodingFailed: 895 return "The invitation could not be prepared." 896 } 897 } 898 }