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 }