RecordSerializer.swift (19521B)
1 import CloudKit 2 import CoreData 3 import Foundation 4 5 /// Pure-function helpers for converting between the app's Core Data / in-memory 6 /// models and CloudKit `CKRecord` objects. Stateless — all context is passed in. 7 enum RecordSerializer { 8 9 // MARK: - Device identity 10 11 /// A stable per-device identifier appended to move and snapshot record 12 /// names to prevent two devices owned by the same iCloud user from 13 /// producing identical record names when both assign the same Lamport 14 /// clock value while offline. 15 /// 16 /// Stored in UserDefaults so it survives app restarts but resets on 17 /// reinstall (which is fine — a reinstalled app has no local moves to 18 /// conflict with). 19 static let localDeviceID: String = { 20 let key = "crossmate.localDeviceID" 21 if let stored = UserDefaults.standard.string(forKey: key) { 22 return stored 23 } 24 let new = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() 25 UserDefaults.standard.set(new, forKey: key) 26 return new 27 }() 28 29 // MARK: - Record names 30 31 static func recordName(forGameID gameID: UUID) -> String { 32 "game-\(gameID.uuidString)" 33 } 34 35 /// One Moves record per `(game, authorID, deviceID)`. Each device only 36 /// writes to its own slot, so there are no write-write conflicts on the 37 /// `cells` field. 38 static func recordName( 39 forMovesInGame gameID: UUID, 40 authorID: String, 41 deviceID: String 42 ) -> String { 43 "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)" 44 } 45 46 /// One player record per (game, author). Each participant only ever 47 /// writes to their own slot, so there are no write-write conflicts on 48 /// the field. 49 static func recordName(forPlayerInGame gameID: UUID, authorID: String) -> String { 50 "player-\(gameID.uuidString)-\(authorID)" 51 } 52 53 /// One Ping record per event. The event timestamp (ms since epoch) makes 54 /// the name unique across events and devices, so repeated pings from the 55 /// same author for the same game don't collide. 56 static func recordName( 57 forPingInGame gameID: UUID, 58 authorID: String, 59 eventTimestampMs: Int64 60 ) -> String { 61 "ping-\(gameID.uuidString)-\(authorID)-\(eventTimestampMs)" 62 } 63 64 // MARK: - Zone 65 66 /// Zone ID for a per-game zone. `ownerName` defaults to the current user 67 /// placeholder; pass an explicit value for shared games where the zone is 68 /// owned by another iCloud account. 69 static func zoneID( 70 for gameID: UUID, 71 ownerName: String = CKCurrentUserDefaultName 72 ) -> CKRecordZone.ID { 73 CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName) 74 } 75 76 // MARK: - Moves record building 77 78 static func movesRecord( 79 from view: MovesValue, 80 zone: CKRecordZone.ID, 81 systemFields: Data? 82 ) throws -> CKRecord { 83 let movesName = recordName( 84 forMovesInGame: view.gameID, 85 authorID: view.authorID, 86 deviceID: view.deviceID 87 ) 88 let record = restoreOrCreate( 89 recordType: "Moves", 90 recordName: movesName, 91 zone: zone, 92 systemFields: systemFields 93 ) 94 95 record["authorID"] = view.authorID as CKRecordValue 96 record["deviceID"] = view.deviceID as CKRecordValue 97 record["updatedAt"] = view.updatedAt as CKRecordValue 98 record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue 99 100 return record 101 } 102 103 static func gameRecord( 104 from entity: GameEntity, 105 recordID: CKRecord.ID, 106 includePuzzleSource: Bool 107 ) -> CKRecord? { 108 guard entity.ckRecordName != nil else { return nil } 109 let record: CKRecord 110 if let fields = entity.ckSystemFields, 111 let restored = decodeRecord(from: fields) { 112 record = restored 113 } else { 114 record = CKRecord(recordType: "Game", recordID: recordID) 115 } 116 populateGameRecord(record, from: entity, includePuzzleSource: includePuzzleSource) 117 return record 118 } 119 120 static func populateGameRecord( 121 _ record: CKRecord, 122 from entity: GameEntity, 123 includePuzzleSource: Bool 124 ) { 125 record["title"] = entity.title as CKRecordValue? 126 record["completedAt"] = entity.completedAt as CKRecordValue? 127 // Owner-side share marker. Propagated so other owner-devices can flip 128 // their `isShared` flag without reading the zone's CKShare directly. 129 record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue? 130 guard includePuzzleSource, let source = entity.puzzleSource else { return } 131 let url = FileManager.default.temporaryDirectory 132 .appendingPathComponent(UUID().uuidString) 133 .appendingPathExtension("xd") 134 try? source.write(to: url, atomically: true, encoding: .utf8) 135 record["puzzleSource"] = CKAsset(fileURL: url) 136 } 137 138 /// Builds a freshly-minted Ping record. Pings are write-once — they have 139 /// no Core Data equivalent and no system-fields archive. 140 /// - `authorID` lets receivers filter out self-sends. 141 /// - `playerName` and `puzzleTitle` let receivers render the alert body. 142 /// - `kind` distinguishes session/join/win/check/reveal events. 143 /// - `scope` is set only for check/reveal kinds. 144 static func pingRecord( 145 gameID: UUID, 146 authorID: String, 147 playerName: String, 148 puzzleTitle: String, 149 eventTimestampMs: Int64, 150 kind: PingKind, 151 scope: PingScope?, 152 zone: CKRecordZone.ID 153 ) -> CKRecord { 154 let name = recordName( 155 forPingInGame: gameID, 156 authorID: authorID, 157 eventTimestampMs: eventTimestampMs 158 ) 159 let recordID = CKRecord.ID(recordName: name, zoneID: zone) 160 let record = CKRecord(recordType: "Ping", recordID: recordID) 161 record["authorID"] = authorID as CKRecordValue 162 record["playerName"] = playerName as CKRecordValue 163 record["puzzleTitle"] = puzzleTitle as CKRecordValue 164 record["kind"] = kind.rawValue as CKRecordValue 165 if let scope { 166 record["scope"] = scope.rawValue as CKRecordValue 167 } 168 return record 169 } 170 171 static func playerRecord( 172 gameID: UUID, 173 authorID: String, 174 name: String, 175 updatedAt: Date, 176 selection: PlayerSelection?, 177 zone: CKRecordZone.ID, 178 systemFields: Data? 179 ) -> CKRecord { 180 let recordName = recordName(forPlayerInGame: gameID, authorID: authorID) 181 let record = restoreOrCreate( 182 recordType: "Player", 183 recordName: recordName, 184 zone: zone, 185 systemFields: systemFields 186 ) 187 188 record["authorID"] = authorID as CKRecordValue 189 record["name"] = name as CKRecordValue 190 record["updatedAt"] = updatedAt as CKRecordValue 191 if let selection { 192 record["selRow"] = Int64(selection.row) as CKRecordValue 193 record["selCol"] = Int64(selection.col) as CKRecordValue 194 record["selDir"] = Int64(selection.direction.rawValue) as CKRecordValue 195 } else { 196 record["selRow"] = nil 197 record["selCol"] = nil 198 record["selDir"] = nil 199 } 200 201 return record 202 } 203 204 /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. Returns 205 /// `nil` if any field is missing — the peer either hasn't published a 206 /// selection yet or has cleared theirs (e.g. left the puzzle view). 207 static func parsePlayerSelection(from record: CKRecord) -> PlayerSelection? { 208 guard let row = record["selRow"] as? Int64, 209 let col = record["selCol"] as? Int64, 210 let dirRaw = record["selDir"] as? Int64, 211 let direction = PlayerSelection.Direction(rawValue: Int(dirRaw)) 212 else { return nil } 213 return PlayerSelection(row: Int(row), col: Int(col), direction: direction) 214 } 215 216 /// Parses an incoming `Player` record name back into its `(gameID, 217 /// authorID)` components. Returns `nil` if the name doesn't match the 218 /// `player-<UUID>-<authorID>` shape. 219 static func parsePlayerRecordName(_ name: String) -> (UUID, String)? { 220 guard name.hasPrefix("player-") else { return nil } 221 let rest = name.dropFirst("player-".count) 222 let uuidLength = 36 223 guard rest.count > uuidLength, 224 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 225 else { return nil } 226 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 227 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 228 let authorPart = String(rest.suffix(from: rest.index(rest.startIndex, offsetBy: uuidLength + 1))) 229 guard !authorPart.isEmpty else { return nil } 230 return (gameID, authorPart) 231 } 232 233 /// Parses an incoming `Moves` CKRecord into a `MovesValue`. Returns `nil` 234 /// if the record name doesn't match the `moves-<gameUUID>-<authorID>-<deviceID>` 235 /// shape or the cells payload fails to decode. 236 static func parseMovesRecord(_ record: CKRecord) -> MovesValue? { 237 guard record.recordType == "Moves" else { return nil } 238 guard let (gameID, authorID, deviceID) = parseMovesRecordName( 239 record.recordID.recordName 240 ) else { return nil } 241 guard let data = record["cells"] as? Data, 242 let cells = try? MovesCodec.decode(data) 243 else { return nil } 244 let updatedAt = record["updatedAt"] as? Date 245 ?? record.modificationDate 246 ?? Date() 247 return MovesValue( 248 gameID: gameID, 249 authorID: authorID, 250 deviceID: deviceID, 251 cells: cells, 252 updatedAt: updatedAt 253 ) 254 } 255 256 /// Parses `moves-<gameUUID>-<authorID>-<deviceID>` into its three parts. 257 /// `deviceID` is the suffix after the final `-`; `authorID` may itself 258 /// contain dashes (e.g. CloudKit user record names with no dashes today, 259 /// but we don't want to assume). 260 static func parseMovesRecordName(_ name: String) -> (UUID, String, String)? { 261 let prefix = "moves-" 262 guard name.hasPrefix(prefix) else { return nil } 263 let rest = name.dropFirst(prefix.count) 264 let uuidLength = 36 265 guard rest.count > uuidLength, 266 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 267 else { return nil } 268 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 269 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 270 let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) 271 let tail = rest[afterUUID...] 272 guard let lastDash = tail.lastIndex(of: "-") else { return nil } 273 let authorID = String(tail[tail.startIndex..<lastDash]) 274 let deviceID = String(tail[tail.index(after: lastDash)...]) 275 guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } 276 return (gameID, authorID, deviceID) 277 } 278 279 // MARK: - Applying incoming CKRecords to Core Data 280 281 /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if 282 /// none exists yet. Moves and Player records can arrive in a different 283 /// fetch batch than the Game record that created the zone — on 284 /// a fresh device CKSyncEngine paginates the initial pull and there is no 285 /// guarantee that Game comes first. Without this stub the parent lookup 286 /// fails, the inbound record is dropped, but CKSyncEngine still advances 287 /// its change token, so the gap is invisible until the next state reset. 288 /// The stub uses empty `title` / `puzzleSource` so `GameSummary.init?` 289 /// filters it out of the library until `applyGameRecord` arrives with 290 /// the real metadata and updates the same row (matched by `ckRecordName`). 291 static func ensureGameEntity( 292 forGameID gameID: UUID, 293 zoneID: CKRecordZone.ID, 294 in ctx: NSManagedObjectContext 295 ) -> GameEntity { 296 let name = recordName(forGameID: gameID) 297 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 298 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 299 req.fetchLimit = 1 300 if let existing = try? ctx.fetch(req).first { return existing } 301 let entity = GameEntity(context: ctx) 302 entity.id = gameID 303 entity.ckRecordName = name 304 entity.ckZoneName = zoneID.zoneName 305 let ownerName = zoneID.ownerName 306 let isOwner = ownerName == CKCurrentUserDefaultName 307 entity.ckZoneOwnerName = isOwner ? nil : ownerName 308 entity.databaseScope = isOwner ? 0 : 1 309 entity.title = "" 310 entity.puzzleSource = "" 311 entity.createdAt = Date() 312 entity.updatedAt = Date() 313 return entity 314 } 315 316 static func applyGameRecord( 317 _ record: CKRecord, 318 to context: NSManagedObjectContext, 319 databaseScope: Int16 = 0 320 ) -> GameEntity { 321 let recordName = record.recordID.recordName 322 let entity = fetchOrCreate( 323 entityName: "GameEntity", 324 recordName: recordName, 325 in: context 326 ) as! GameEntity 327 328 // Recover the UUID from the record name ("game-<UUID>") so the 329 // library query, which filters on `entity.id`, doesn't silently drop 330 // newly-synced games. 331 if entity.id == nil { 332 let uuidString = String(recordName.dropFirst("game-".count)) 333 entity.id = UUID(uuidString: uuidString) 334 } 335 336 // Seed createdAt/updatedAt from the server record so the library 337 // can order newly-arrived games. The CKRecord timestamps are the 338 // source of truth when we don't have a local creation event. 339 if entity.createdAt == nil { 340 entity.createdAt = record.creationDate ?? Date() 341 } 342 entity.updatedAt = record.modificationDate ?? entity.updatedAt ?? Date() 343 344 entity.ckRecordName = recordName 345 entity.ckSystemFields = encodeSystemFields(of: record) 346 entity.title = record["title"] as? String ?? entity.title 347 entity.completedAt = record["completedAt"] as? Date 348 entity.databaseScope = databaseScope 349 // Owner-side share marker — set on the device that created the share 350 // and round-tripped via the Game record so other owner-devices learn 351 // the game is shared. On participant devices `databaseScope == 1` 352 // already implies shared, but keeping the field in sync is harmless. 353 if let shareRecordName = record["shareRecordName"] as? String { 354 entity.ckShareRecordName = shareRecordName 355 } 356 357 // Persist the zone identity so outbound moves use the right zone ID. 358 entity.ckZoneName = record.recordID.zoneID.zoneName 359 let ownerName = record.recordID.zoneID.ownerName 360 entity.ckZoneOwnerName = ownerName == CKCurrentUserDefaultName ? nil : ownerName 361 362 if let asset = record["puzzleSource"] as? CKAsset, 363 let fileURL = asset.fileURL, 364 let source = try? String(contentsOf: fileURL, encoding: .utf8) { 365 entity.puzzleSource = source 366 if let xd = try? XD.parse(source) { 367 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 368 entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) 369 } 370 } 371 372 return entity 373 } 374 375 /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight 376 /// off the record so any forward-compat fields the encoder added are 377 /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the 378 /// record is fresher. 379 static func applyMovesRecord( 380 _ record: CKRecord, 381 value: MovesValue, 382 to ctx: NSManagedObjectContext, 383 localAuthorID: String? = nil 384 ) { 385 let ckName = record.recordID.recordName 386 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 387 req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) 388 req.fetchLimit = 1 389 390 let entity: MovesEntity 391 let foundExisting: Bool 392 if let existing = try? ctx.fetch(req).first { 393 entity = existing 394 foundExisting = true 395 } else { 396 let game = ensureGameEntity( 397 forGameID: value.gameID, 398 zoneID: record.recordID.zoneID, 399 in: ctx 400 ) 401 entity = MovesEntity(context: ctx) 402 entity.game = game 403 foundExisting = false 404 } 405 406 // Always adopt system fields so future saves target the server's 407 // current change tag. If this is our own per-device row and it already 408 // exists locally, the local value state is authoritative; tokenless 409 // push-driven direct fetches can re-deliver an older server copy while 410 // newer edits are still queued for upload. 411 entity.ckRecordName = ckName 412 entity.ckSystemFields = encodeSystemFields(of: record) 413 entity.authorID = value.authorID 414 entity.deviceID = value.deviceID 415 let isLocalDeviceRow = value.authorID == localAuthorID 416 && value.deviceID == localDeviceID 417 guard !foundExisting || !isLocalDeviceRow else { return } 418 entity.updatedAt = value.updatedAt 419 entity.cells = (record["cells"] as? Data) ?? Data() 420 421 if let game = entity.game, 422 game.updatedAt.map({ $0 < value.updatedAt }) ?? true { 423 game.updatedAt = value.updatedAt 424 } 425 } 426 427 // MARK: - System fields encode/decode 428 429 static func encodeSystemFields(of record: CKRecord) -> Data? { 430 let coder = NSKeyedArchiver(requiringSecureCoding: true) 431 record.encodeSystemFields(with: coder) 432 coder.finishEncoding() 433 return coder.encodedData 434 } 435 436 static func decodeRecord(from data: Data) -> CKRecord? { 437 guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } 438 coder.requiresSecureCoding = true 439 let record = CKRecord(coder: coder) 440 coder.finishDecoding() 441 return record 442 } 443 444 // MARK: - Private helpers 445 446 /// Restores a `CKRecord` from archived system fields (preserving the 447 /// server change tag) or creates a fresh one if no archive is available. 448 private static func restoreOrCreate( 449 recordType: String, 450 recordName: String, 451 zone: CKRecordZone.ID, 452 systemFields: Data? 453 ) -> CKRecord { 454 if let data = systemFields, let restored = decodeRecord(from: data) { 455 return restored 456 } 457 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 458 return CKRecord(recordType: recordType, recordID: recordID) 459 } 460 461 private static func fetchOrCreate( 462 entityName: String, 463 recordName: String, 464 in context: NSManagedObjectContext 465 ) -> NSManagedObject { 466 let request = NSFetchRequest<NSManagedObject>(entityName: entityName) 467 request.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 468 request.fetchLimit = 1 469 if let existing = try? context.fetch(request).first { 470 return existing 471 } 472 return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context) 473 } 474 475 }