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 }