crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }