crossmate

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

CloudZones.swift (8663B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 extension SyncEngine {
      6     struct ZoneInfo {
      7         let scope: Int16
      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: 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: Int16,
     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
     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 zones carry `.invite` / `.friend` pings but no
     92             // GameEntity, so they're appended explicitly. The owner sees the
     93             // zone in the private DB
     94             // (scope 0); the participant sees it in the shared DB
     95             // (scope 1). Blocked friends are skipped so we stop reading
     96             // anything from them. Floor is `.distantPast`: any unseen
     97             // invite should be processed.
     98             let friendReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
     99             friendReq.predicate = NSPredicate(
    100                 format: "databaseScope == %d AND isBlocked == NO",
    101                 scope
    102             )
    103             for friend in (try? ctx.fetch(friendReq)) ?? [] {
    104                 guard let zoneName = friend.friendZoneName,
    105                       let ownerName = friend.friendZoneOwnerName
    106                 else { continue }
    107                 let key = "\(ownerName)|\(zoneName)"
    108                 guard seen.insert(key).inserted else { continue }
    109                 result.append((
    110                     CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
    111                     Date(timeIntervalSince1970: 0)
    112                 ))
    113             }
    114             return result
    115         }
    116     }
    117 
    118     nonisolated func incompleteKnownZones(
    119         forScope scope: Int16,
    120         in ctx: NSManagedObjectContext
    121     ) -> [ActivityZoneInfo] {
    122         ctx.performAndWait {
    123             let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    124             req.predicate = NSPredicate(
    125                 format: "databaseScope == %d AND completedAt == nil AND isAccessRevoked == NO",
    126                 scope
    127             )
    128             guard let entities = try? ctx.fetch(req) else { return [] }
    129             var seen = Set<String>()
    130             var result: [ActivityZoneInfo] = []
    131             for entity in entities {
    132                 guard let gameID = entity.id else { continue }
    133                 let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    134                 let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
    135                 let key = "\(ownerName)|\(zoneName)"
    136                 guard seen.insert(key).inserted else { continue }
    137                 result.append(ActivityZoneInfo(
    138                     gameID: gameID,
    139                     zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
    140                     title: PuzzleNotificationText.title(for: entity)
    141                 ))
    142             }
    143             return result
    144         }
    145     }
    146 
    147     nonisolated func friendZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] {
    148         let ctx = persistence.container.newBackgroundContext()
    149         return ctx.performAndWait {
    150             let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
    151             req.predicate = NSPredicate(
    152                 format: "databaseScope == %d AND isBlocked == NO",
    153                 scope
    154             )
    155             var seen = Set<String>()
    156             var result: [CKRecordZone.ID] = []
    157             for friend in (try? ctx.fetch(req)) ?? [] {
    158                 guard let zoneName = friend.friendZoneName,
    159                       let ownerName = friend.friendZoneOwnerName
    160                 else { continue }
    161                 let key = "\(ownerName)|\(zoneName)"
    162                 guard seen.insert(key).inserted else { continue }
    163                 result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName))
    164             }
    165             return result
    166         }
    167     }
    168 
    169     /// Extracts the game UUID from any of our record name formats:
    170     /// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`.
    171     nonisolated func gameID(fromRecordName name: String) -> UUID? {
    172         if name.hasPrefix("game-") {
    173             return UUID(uuidString: String(name.dropFirst("game-".count)))
    174         }
    175         let prefix: String
    176         if name.hasPrefix("moves-") { prefix = "moves-" }
    177         else if name.hasPrefix("player-") { prefix = "player-" }
    178         else if name.hasPrefix("ping-") { prefix = "ping-" }
    179         else { return nil }
    180         let rest = name.dropFirst(prefix.count)
    181         return UUID(uuidString: String(rest.prefix(36)))
    182     }
    183 }