crossmate

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

CloudZones.swift (9406B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 extension SyncEngine {
      6     struct ZoneInfo {
      7         let scope: DatabaseScope
      8         let zoneID: CKRecordZone.ID
      9         let isAccessRevoked: Bool
     10         let isCloudConfirmed: Bool
     11     }
     12 
     13     struct ActivityZoneInfo: Sendable {
     14         let gameID: UUID
     15         let zoneID: CKRecordZone.ID
     16         let title: String
     17     }
     18 
     19     /// Looks up a game's scope and zone ID from Core Data. Returns `nil` if
     20     /// the entity can't be found. Not `async` — uses `performAndWait` so it
     21     /// can be called from non-async actor context.
     22     nonisolated func zoneInfo(
     23         forGameID gameID: UUID,
     24         in ctx: NSManagedObjectContext
     25     ) -> ZoneInfo? {
     26         ctx.performAndWait {
     27             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     28             req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     29             req.fetchLimit = 1
     30             guard let entity = try? ctx.fetch(req).first else { return nil }
     31             let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
     32             let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
     33             return ZoneInfo(
     34                 scope: DatabaseScope(entityValue: entity.databaseScope),
     35                 zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
     36                 isAccessRevoked: entity.isAccessRevoked,
     37                 isCloudConfirmed: entity.ckSystemFields != nil
     38             )
     39         }
     40     }
     41 
     42     /// Enumerates every known game zone for the given database scope, paired
     43     /// with the `createdAt` of the corresponding GameEntity. The createdAt
     44     /// timestamp is used as the per-zone floor for the ping fast path: pings
     45     /// older than the moment this device first knew about the game can't be
     46     /// of interest (for shared games, they pre-date our join; for owned
     47     /// games, they pre-date the game's existence).
     48     nonisolated func knownZones(
     49         forScope scope: DatabaseScope,
     50         onlyIncomplete: Bool = false,
     51         in ctx: NSManagedObjectContext
     52     ) -> [(zoneID: CKRecordZone.ID, createdAt: Date)] {
     53         ctx.performAndWait {
     54             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     55             // Skip access-revoked entries so an orphaned shared zone — e.g.
     56             // one whose participant binding became invalid and now returns
     57             // "Cannot convert userId to dsId" on every query — stops being
     58             // re-queried by the direct fetch paths.
     59             //
     60             // `onlyIncomplete` additionally drops finished puzzles' game
     61             // zones (keeps only completedAt == nil). Only the ping fast path
     62             // passes it: that path is a latency shortcut, and a completed
     63             // puzzle has no live collaboration, so a late `.invite`/`.hail`
     64             // ping there still arrives via CKSyncEngine's own push-driven
     65             // fetchedRecordZoneChanges, which surfaces Ping records for
     66             // every tracked zone regardless of completion. It must stay
     67             // opt-in — discoverNewZonesDirect diffs the *full* known set
     68             // against the server to spot new zones, so
     69             // excluding completed games there would make every finished zone
     70             // look new and re-pull it on each discovery. The account and
     71             // friend zones below are appended unconditionally: they carry
     72             // .opened/.invite/.friend, have no GameEntity, and no completion.
     73             req.predicate = NSPredicate(
     74                 format: onlyIncomplete
     75                     ? "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO"
     76                     : "databaseScope == %d AND isAccessRevoked == NO",
     77                 scope.rawValue
     78             )
     79             guard let entities = try? ctx.fetch(req) else { return [] }
     80             var seen = Set<String>()
     81             var result: [(CKRecordZone.ID, Date)] = []
     82             for entity in entities {
     83                 guard let gameID = entity.id else { continue }
     84                 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
     85                 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
     86                 let key = "\(ownerName)|\(zoneName)"
     87                 guard seen.insert(key).inserted else { continue }
     88                 let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0)
     89                 result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt))
     90             }
     91             // Friend mailboxes carry `.invite` / `.friend` pings but no
     92             // GameEntity, so they're appended explicitly. Each friend has two
     93             // zones: our inbox (we own it — private DB, scope 0) and the
     94             // friend's inbox, i.e. our outbox (we joined it — shared DB,
     95             // scope 1). At scope 0 we keep our inbox even for a blocked friend
     96             // (we own it; the downgraded friend can't write to it anyway); at
     97             // scope 1 we skip blocked friends — we won't write to them while
     98             // blocked. Floor is `.distantPast`: any unseen invite should be
     99             // processed.
    100             let friendReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    101             friendReq.predicate = scope == .private
    102                 ? NSPredicate(value: true)
    103                 : NSPredicate(format: "isBlocked == NO")
    104             for friend in (try? ctx.fetch(friendReq)) ?? [] {
    105                 guard let pairKey = friend.pairKey,
    106                       let authorID = friend.authorID
    107                 else { continue }
    108                 let zoneID = scope == .private
    109                     ? FriendZone.inboxZoneID(pairKey: pairKey)
    110                     : FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID)
    111                 let key = "\(zoneID.ownerName)|\(zoneID.zoneName)"
    112                 guard seen.insert(key).inserted else { continue }
    113                 result.append((zoneID, Date(timeIntervalSince1970: 0)))
    114             }
    115             return result
    116         }
    117     }
    118 
    119     nonisolated func incompleteKnownZones(
    120         forScope scope: DatabaseScope,
    121         in ctx: NSManagedObjectContext
    122     ) -> [ActivityZoneInfo] {
    123         ctx.performAndWait {
    124             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    125             req.predicate = NSPredicate(
    126                 format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO",
    127                 scope.rawValue
    128             )
    129             guard let entities = try? ctx.fetch(req) else { return [] }
    130             var seen = Set<String>()
    131             var result: [ActivityZoneInfo] = []
    132             for entity in entities {
    133                 guard let gameID = entity.id else { continue }
    134                 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    135                 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
    136                 let key = "\(ownerName)|\(zoneName)"
    137                 guard seen.insert(key).inserted else { continue }
    138                 result.append(ActivityZoneInfo(
    139                     gameID: gameID,
    140                     zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
    141                     title: PuzzleNotificationText.title(for: entity)
    142                 ))
    143             }
    144             return result
    145         }
    146     }
    147 
    148     nonisolated func friendZoneIDs(forScope scope: DatabaseScope) -> [CKRecordZone.ID] {
    149         let ctx = persistence.container.newBackgroundContext()
    150         return ctx.performAndWait {
    151             // .private → our inboxes (we own them, kept even when blocked);
    152             // .shared → our outboxes (the friends' inboxes in the shared DB,
    153             // skipped while blocked). Both derived from the pair.
    154             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    155             req.predicate = scope == .private
    156                 ? NSPredicate(value: true)
    157                 : NSPredicate(format: "isBlocked == NO")
    158             var seen = Set<String>()
    159             var result: [CKRecordZone.ID] = []
    160             for friend in (try? ctx.fetch(req)) ?? [] {
    161                 guard let pairKey = friend.pairKey,
    162                       let authorID = friend.authorID
    163                 else { continue }
    164                 let zoneID = scope == .private
    165                     ? FriendZone.inboxZoneID(pairKey: pairKey)
    166                     : FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID)
    167                 let key = "\(zoneID.ownerName)|\(zoneID.zoneName)"
    168                 guard seen.insert(key).inserted else { continue }
    169                 result.append(zoneID)
    170             }
    171             return result
    172         }
    173     }
    174 
    175     /// Extracts the game UUID from any of our record name formats:
    176     /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`.
    177     nonisolated func gameID(fromRecordName name: String) -> UUID? {
    178         if name.hasPrefix("game-") {
    179             return UUID(uuidString: String(name.dropFirst("game-".count)))
    180         }
    181         let prefix: String
    182         if name.hasPrefix("moves-") { prefix = "moves-" }
    183         else if name.hasPrefix("player-") { prefix = "player-" }
    184         else if name.hasPrefix("ping-") { prefix = "ping-" }
    185         else { return nil }
    186         let rest = name.dropFirst(prefix.count)
    187         return UUID(uuidString: String(rest.prefix(36)))
    188     }
    189 }