RecordSerializer.swift (56575B)
1 import CloudKit 2 import CoreData 3 import CryptoKit 4 import Foundation 5 6 /// Pure-function helpers for converting between the app's Core Data / in-memory 7 /// models and CloudKit `CKRecord` objects. Stateless — all context is passed in. 8 enum RecordSerializer { 9 10 // MARK: - Direct fetch key sets 11 12 static let gameDesiredKeys: [CKRecord.FieldKey] = [ 13 "title", 14 "completedAt", 15 "completedBy", 16 "shareRecordName", 17 "engagement", 18 "notification", 19 "puzzleSource", 20 ] 21 22 static let movesDesiredKeys: [CKRecord.FieldKey] = [ 23 "authorID", 24 "deviceID", 25 "cells", 26 "updatedAt", 27 ] 28 29 static let playerDesiredKeys: [CKRecord.FieldKey] = [ 30 "authorID", 31 "name", 32 "updatedAt", 33 "selRow", 34 "selCol", 35 "selDir", 36 "readAt", 37 "readThrough", 38 "sessionSnapshot", 39 "timeLog", 40 "pushAddress", 41 ] 42 43 static let pingDesiredKeys: [CKRecord.FieldKey] = [ 44 "authorID", 45 "deviceID", 46 "playerName", 47 "puzzleTitle", 48 "kind", 49 "payload", 50 "addressee", 51 ] 52 53 static let pingDeletionDesiredKeys: [CKRecord.FieldKey] = [ 54 "authorID", 55 "kind", 56 ] 57 58 // MARK: - Device identity 59 60 /// A stable per-device identifier appended to move and snapshot record 61 /// names to prevent two devices owned by the same iCloud user from 62 /// producing identical record names when both assign the same Lamport 63 /// clock value while offline. 64 /// 65 /// Stored in UserDefaults so it survives app restarts but resets on 66 /// reinstall (which is fine — a reinstalled app has no local moves to 67 /// conflict with). 68 static let localDeviceID: String = { 69 let key = "crossmate.localDeviceID" 70 if let stored = UserDefaults.standard.string(forKey: key) { 71 return stored 72 } 73 let new = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() 74 UserDefaults.standard.set(new, forKey: key) 75 return new 76 }() 77 78 // MARK: - Record names 79 80 static func recordName(forGameID gameID: UUID) -> String { 81 "game-\(gameID.uuidString)" 82 } 83 84 /// Recovers the game UUID from a `"game-<UUID>"` record or zone name — the 85 /// inverse of `recordName(forGameID:)`. Returns nil when the name isn't a 86 /// game name or the UUID doesn't parse. A game's zone name and its root 87 /// record name are identical, so this also resolves a share's zone. 88 static func gameID(fromGameRecordName name: String) -> UUID? { 89 guard name.hasPrefix("game-") else { return nil } 90 return UUID(uuidString: String(name.dropFirst("game-".count))) 91 } 92 93 /// One Moves record per `(game, authorID, deviceID)`. Each device only 94 /// writes to its own slot, so there are no write-write conflicts on the 95 /// `cells` field. 96 static func recordName( 97 forMovesInGame gameID: UUID, 98 authorID: String, 99 deviceID: String 100 ) -> String { 101 "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)" 102 } 103 104 /// One Journal record per `(game, authorID, deviceID)` — this device's 105 /// whole local move log, uploaded once at completion (Phase 2). Same 106 /// `(game, author, device)` shape as the Moves record so collaborators' 107 /// uploads stay distinct and mergeable by timestamp for replay. 108 static func recordName( 109 forJournalInGame gameID: UUID, 110 authorID: String, 111 deviceID: String 112 ) -> String { 113 "journal-\(gameID.uuidString)-\(authorID)-\(deviceID)" 114 } 115 116 /// One player record per (game, author). Each participant only ever 117 /// writes to their own slot, so there are no write-write conflicts on 118 /// the field. 119 static func recordName(forPlayerInGame gameID: UUID, authorID: String) -> String { 120 "player-\(gameID.uuidString)-\(authorID)" 121 } 122 123 /// One Ping record per event. `deviceID` keeps cross-device writes from 124 /// the same iCloud user unique (authorID is identical across that user's 125 /// devices), and the event timestamp covers repeated pings from the same 126 /// device. 127 static func recordName( 128 forPingInGame gameID: UUID, 129 authorID: String, 130 deviceID: String, 131 eventTimestampMs: Int64 132 ) -> String { 133 "ping-\(gameID.uuidString)-\(authorID)-\(deviceID)-\(eventTimestampMs)" 134 } 135 136 /// One `Decision` record per `(kind, key)`. A durable, per-user fact that 137 /// must agree across a single iCloud user's own devices — the durable 138 /// counterpart to the transient `Ping`. Lives in the account zone; the 139 /// deterministic name makes every write an idempotent upsert. `kind` 140 /// carries no dashes (so the first dash after the prefix splits cleanly); 141 /// `key` may contain dashes. 142 static func decisionRecordName(kind: String, key: String) -> String { 143 "decision-\(kind)-\(key)" 144 } 145 146 /// Parses `decision-<kind>-<key>`. `kind` is the segment up to the first 147 /// dash after the prefix; `key` is the remainder. 148 static func parseDecisionRecordName(_ name: String) -> (kind: String, key: String)? { 149 let prefix = "decision-" 150 guard name.hasPrefix(prefix) else { return nil } 151 let rest = name.dropFirst(prefix.count) 152 guard let dash = rest.firstIndex(of: "-") else { return nil } 153 let kind = String(rest[rest.startIndex..<dash]) 154 let key = String(rest[rest.index(after: dash)...]) 155 guard !kind.isEmpty, !key.isEmpty else { return nil } 156 return (kind, key) 157 } 158 159 /// Kind for the display-name Decision: `decision-name-<authorID>`, payload 160 /// = the display name, `version` = the author's monotonic rename 161 /// generation. The author writes their own copy into their account zone 162 /// (own-device convergence and restore durability) and into every friend 163 /// zone they participate in (the friend's devices read it from there) — 164 /// names never sync through any other channel. 165 static let nameDecisionKind = "name" 166 167 static func nameDecisionName(authorID: String) -> String { 168 decisionRecordName(kind: nameDecisionKind, key: authorID) 169 } 170 171 /// Parses a display-name Decision into its subject author, name, and 172 /// version. Returns `nil` for any other decision or an empty payload. 173 static func parseNameDecision( 174 _ record: CKRecord 175 ) -> (authorID: String, name: String, version: Int64)? { 176 guard record.recordType == "Decision", 177 let (kind, key) = parseDecisionRecordName(record.recordID.recordName), 178 kind == nameDecisionKind, 179 ((record["kind"] as? String) ?? nameDecisionKind) == nameDecisionKind, 180 let name = record["payload"] as? String, 181 !name.isEmpty 182 else { return nil } 183 return (key, name, decisionVersion(record)) 184 } 185 186 /// Kind for the friend-nickname Decision: `decision-nickname-<authorID>`, 187 /// payload = the nickname this user privately calls that friend (absent or 188 /// empty = cleared, fall back to the friend's own name), `version` = this 189 /// user's monotonic rename generation for that friend. Lives only in the 190 /// account zone — it's the user's own label, never shared with the friend. 191 static let nicknameDecisionKind = "nickname" 192 193 static let accountDecisionKind = "account" 194 static let accountPushAddressDecisionKey = "pushAddress" 195 /// Key for the account-wide push *secret* decision. The secret is the HMAC 196 /// key from which every per-game push address is derived (see 197 /// `deriveGameAddress`); it converges across the account's own devices the 198 /// same way the account address does, and is never sent to peers or the 199 /// push worker — only the derived per-game addresses are. 200 static let accountPushSecretDecisionKey = "pushSecret" 201 202 static var accountPushAddressDecisionName: String { 203 decisionRecordName(kind: accountDecisionKind, key: accountPushAddressDecisionKey) 204 } 205 206 static var accountPushSecretDecisionName: String { 207 decisionRecordName(kind: accountDecisionKind, key: accountPushSecretDecisionKey) 208 } 209 210 static func parseAccountPushAddressDecision(_ record: CKRecord) -> String? { 211 guard record.recordType == "Decision", 212 record.recordID.recordName == accountPushAddressDecisionName, 213 (record["kind"] as? String) == accountDecisionKind, 214 let address = record["payload"] as? String, 215 !address.isEmpty 216 else { return nil } 217 return address 218 } 219 220 /// Default generation for a `version`-less Decision — any record written by 221 /// the pre-rotation code. Matched to the value a fresh mint uses so legacy 222 /// and freshly-minted secrets share a generation and converge via the 223 /// equal-version "server wins" rule, while a deliberate rotation (2+) 224 /// supersedes them. Mapping to 0 instead would let the first post-update 225 /// mint clobber an already-converged legacy secret. 226 static let decisionBaseVersion: Int64 = 1 227 228 /// The monotonic generation of a Decision. Higher wins: an inbound or 229 /// conflicting record at a higher version supersedes the local value; equal 230 /// versions converge on whoever reached the server first. Absent (legacy) 231 /// records report `decisionBaseVersion`. 232 static func decisionVersion(_ record: CKRecord) -> Int64 { 233 (record["version"] as? Int64) ?? decisionBaseVersion 234 } 235 236 static func parseAccountPushSecretDecision( 237 _ record: CKRecord 238 ) -> (secret: String, version: Int64)? { 239 guard record.recordType == "Decision", 240 record.recordID.recordName == accountPushSecretDecisionName, 241 (record["kind"] as? String) == accountDecisionKind, 242 let secret = record["payload"] as? String, 243 !secret.isEmpty 244 else { return nil } 245 return (secret, decisionVersion(record)) 246 } 247 248 /// Derives this account's push address for one game as 249 /// `HMAC-SHA256(secret, gameID)`, base64url-encoded. Deterministic, so every 250 /// one of the account's devices computes the identical address for a game 251 /// without any negotiation, and per-game scoped: a peer holding one game's 252 /// address can't compute another's without the secret, which never leaves 253 /// the account's devices. Rotation is by changing the secret. 254 static func deriveGameAddress(secret: String, gameID: UUID) -> String { 255 let key = SymmetricKey(data: Data(secret.utf8)) 256 let mac = HMAC<SHA256>.authenticationCode( 257 for: Data(gameID.uuidString.utf8), 258 using: key 259 ) 260 return Data(mac).base64EncodedString() 261 .replacingOccurrences(of: "+", with: "-") 262 .replacingOccurrences(of: "/", with: "_") 263 .replacingOccurrences(of: "=", with: "") 264 } 265 266 // MARK: - Zone 267 268 /// Zone ID for a per-game zone. `ownerName` defaults to the current user 269 /// placeholder; pass an explicit value for shared games where the zone is 270 /// owned by another iCloud account. 271 static func zoneID( 272 for gameID: UUID, 273 ownerName: String = CKCurrentUserDefaultName 274 ) -> CKRecordZone.ID { 275 CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName) 276 } 277 278 /// Zone ID for the user's account-wide zone in the private database. Holds 279 /// records that coordinate state between a single iCloud user's own 280 /// devices — never shared with collaborators, since the private database 281 /// itself isn't reachable to anyone else. 282 static let accountZoneID = CKRecordZone.ID( 283 zoneName: "account", 284 ownerName: CKCurrentUserDefaultName 285 ) 286 287 // MARK: - Moves record building 288 289 static func movesRecord( 290 from view: MovesValue, 291 zone: CKRecordZone.ID, 292 systemFields: Data? 293 ) throws -> CKRecord { 294 let movesName = recordName( 295 forMovesInGame: view.gameID, 296 authorID: view.authorID, 297 deviceID: view.deviceID 298 ) 299 let record = restoreOrCreate( 300 recordType: "Moves", 301 recordName: movesName, 302 zone: zone, 303 systemFields: systemFields 304 ) 305 306 record["authorID"] = view.authorID as CKRecordValue 307 record["deviceID"] = view.deviceID as CKRecordValue 308 record["updatedAt"] = view.updatedAt as CKRecordValue 309 record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue 310 311 return record 312 } 313 314 // MARK: - Journal record building 315 316 /// Builds the `Journal` record carrying this device's full move log as a 317 /// `CKAsset`. Write-once at completion, so there is no system-fields 318 /// archive (mirrors `Ping`/`Decision`): a fresh record each build, and a 319 /// re-send of an already-uploaded journal is a benign conflict the send 320 /// path drops. The encoded entries are written to a temp file the same way 321 /// `populateGameRecord` stages `puzzleSource` — CloudKit copies the asset 322 /// on upload and the OS reaps the temporary directory. 323 static func journalRecord( 324 gameID: UUID, 325 authorID: String, 326 deviceID: String, 327 updatedAt: Date, 328 entries: [JournalValue], 329 zone: CKRecordZone.ID 330 ) throws -> CKRecord { 331 let name = recordName(forJournalInGame: gameID, authorID: authorID, deviceID: deviceID) 332 let recordID = CKRecord.ID(recordName: name, zoneID: zone) 333 let record = CKRecord(recordType: "Journal", recordID: recordID) 334 record["authorID"] = authorID as CKRecordValue 335 record["deviceID"] = deviceID as CKRecordValue 336 record["updatedAt"] = updatedAt as CKRecordValue 337 338 let data = try JournalCodec.encode(entries) 339 let url = FileManager.default.temporaryDirectory 340 .appendingPathComponent(UUID().uuidString) 341 .appendingPathExtension("json") 342 try data.write(to: url, options: .atomic) 343 record["entries"] = CKAsset(fileURL: url) 344 345 return record 346 } 347 348 static func gameRecord( 349 from entity: GameEntity, 350 recordID: CKRecord.ID, 351 includePuzzleSource: Bool 352 ) -> CKRecord? { 353 guard entity.ckRecordName != nil else { return nil } 354 let record: CKRecord 355 if let fields = entity.ckSystemFields, 356 let restored = decodeRecord(from: fields) { 357 record = restored 358 } else { 359 record = CKRecord(recordType: "Game", recordID: recordID) 360 } 361 populateGameRecord(record, from: entity, includePuzzleSource: includePuzzleSource) 362 return record 363 } 364 365 static func populateGameRecord( 366 _ record: CKRecord, 367 from entity: GameEntity, 368 includePuzzleSource: Bool 369 ) { 370 // `title` and the `shareRecordName` marker are owner-authoritative: the 371 // title comes from the puzzle the owner authored, and only owner devices 372 // track the share record. A participant only ever re-saves this record to 373 // mint the engagement/notification creds below, and at join time its 374 // local `title` is still the transient "Joining…" placeholder 375 // (`SyncEngine.handleFetchedDatabaseChanges`) until the owner's Game 376 // record lands. Writing it from a participant would LWW-clobber the real 377 // title on the shared record for everyone — so a non-owner leaves these 378 // fields untouched and the server keeps the owner's value. 379 let isOwner = entity.databaseScope == 0 380 if isOwner { 381 record["title"] = entity.title as CKRecordValue? 382 // Owner-side share marker. Propagated so other owner-devices can flip 383 // their `isShared` flag without reading the zone's CKShare directly. 384 record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue? 385 } 386 record["completedAt"] = entity.completedAt as CKRecordValue? 387 // Solver's authorID on a win; nil for a resignation. Single-writer 388 // (the device that first completes the game) so plain LWW is safe. 389 record["completedBy"] = entity.completedBy as CKRecordValue? 390 // The shared live-engagement room credentials (encoded 391 // EngagementRoomCredentials). Any present participant may mint these 392 // when the field is empty; convergence is plain record-level LWW, and 393 // peers connect to whatever creds the field currently holds. 394 record["engagement"] = entity.engagement as CKRecordValue? 395 // The shared per-game notification credentials (encoded 396 // GamePushCredentials: the push auth secret + credID, plus the 397 // worker-blind content key the payload is encrypted under). Synced to 398 // participants like `engagement`; any participant may mint it when 399 // empty, and record-level LWW converges concurrent mints. 400 record["notification"] = entity.notification as CKRecordValue? 401 guard includePuzzleSource, let source = entity.puzzleSource else { return } 402 let url = FileManager.default.temporaryDirectory 403 .appendingPathComponent(UUID().uuidString) 404 .appendingPathExtension("xd") 405 try? source.write(to: url, atomically: true, encoding: .utf8) 406 record["puzzleSource"] = CKAsset(fileURL: url) 407 } 408 409 /// Builds a freshly-minted Ping record. Pings are write-once — they have 410 /// no Core Data equivalent and no system-fields archive. 411 /// - `authorID` + `deviceID` together let receivers filter out self-sends. 412 /// authorID alone is insufficient for kinds (e.g. `.opened`) that fire 413 /// between a single user's own devices, where authorID is identical. 414 /// - `playerName` and `puzzleTitle` let receivers render the alert body. 415 /// - `kind` distinguishes the remaining bootstrap kinds (.join / 416 /// .friend / .invite / .hail). 417 static func pingRecord( 418 gameID: UUID, 419 authorID: String, 420 deviceID: String, 421 playerName: String, 422 puzzleTitle: String, 423 eventTimestampMs: Int64, 424 kind: PingKind, 425 payload: String? = nil, 426 addressee: String? = nil, 427 zone: CKRecordZone.ID 428 ) -> CKRecord { 429 let name = recordName( 430 forPingInGame: gameID, 431 authorID: authorID, 432 deviceID: deviceID, 433 eventTimestampMs: eventTimestampMs 434 ) 435 let recordID = CKRecord.ID(recordName: name, zoneID: zone) 436 let record = CKRecord(recordType: "Ping", recordID: recordID) 437 record["authorID"] = authorID as CKRecordValue 438 record["deviceID"] = deviceID as CKRecordValue 439 record["playerName"] = playerName as CKRecordValue 440 record["puzzleTitle"] = puzzleTitle as CKRecordValue 441 record["kind"] = kind.rawValue as CKRecordValue 442 // Directed pings target one player by authorID; nil ⇒ broadcast (every 443 // recipient acts on it). 444 if let addressee { 445 record["addressee"] = addressee as CKRecordValue 446 } 447 if let payload { 448 record["payload"] = payload as CKRecordValue 449 } 450 return record 451 } 452 453 /// Builds a `Decision` record. The identity (`kind` + `key`) lives in the 454 /// record name, which keeps every write an idempotent upsert; `key` is not 455 /// duplicated as a field. `payload` is the generic, kind-specific extra 456 /// slot — empty for `block`, where presence alone is the fact — mirroring 457 /// `Ping.payload`. `systemFields` is the archived server record (with its 458 /// change tag): pass it so a re-send carries the current tag and CloudKit 459 /// accepts the update (e.g. rotating the push secret) instead of rejecting 460 /// it as a colliding create. Decisions are therefore upsertable, not 461 /// write-once; a payload-less write clears any value a restored record held. 462 static func decisionRecord( 463 kind: String, 464 key: String, 465 payload: String? = nil, 466 zone: CKRecordZone.ID, 467 systemFields: Data? = nil, 468 version: Int64? = nil 469 ) -> CKRecord { 470 let name = decisionRecordName(kind: kind, key: key) 471 let record = restoreOrCreate( 472 recordType: "Decision", 473 recordName: name, 474 zone: zone, 475 systemFields: systemFields 476 ) 477 record["kind"] = kind as CKRecordValue 478 record["payload"] = payload.map { $0 as CKRecordValue } 479 record["createdAt"] = Date() as CKRecordValue 480 record["version"] = version.map { $0 as CKRecordValue } 481 return record 482 } 483 484 static func playerRecord( 485 gameID: UUID, 486 authorID: String, 487 name: String, 488 updatedAt: Date, 489 selection: PlayerSelection?, 490 readAt: Date? = nil, 491 readThrough: Date? = nil, 492 sessionSnapshot: Data? = nil, 493 timeLog: Data? = nil, 494 pushAddress: String? = nil, 495 zone: CKRecordZone.ID, 496 systemFields: Data? 497 ) -> CKRecord { 498 let recordName = recordName(forPlayerInGame: gameID, authorID: authorID) 499 let record = restoreOrCreate( 500 recordType: "Player", 501 recordName: recordName, 502 zone: zone, 503 systemFields: systemFields 504 ) 505 506 record["authorID"] = authorID as CKRecordValue 507 record["name"] = name as CKRecordValue 508 record["updatedAt"] = updatedAt as CKRecordValue 509 if let selection { 510 record["selRow"] = Int64(selection.row) as CKRecordValue 511 record["selCol"] = Int64(selection.col) as CKRecordValue 512 record["selDir"] = Int64(selection.direction.rawValue) as CKRecordValue 513 } else { 514 record["selRow"] = nil 515 record["selCol"] = nil 516 record["selDir"] = nil 517 } 518 // `readAt` is the forward-dated presence lease (see `GameStore`'s 519 // TODO(v4): this field should be renamed to a presence name). 520 if let readAt { 521 record["readAt"] = readAt as CKRecordValue 522 } else { 523 record["readAt"] = nil 524 } 525 // `readThrough` is the true read watermark — the latest other-author 526 // move time this account has actually seen. Never forward-dated, so a 527 // peer's session-end summary windows only on moves we genuinely missed. 528 if let readThrough { 529 record["readThrough"] = readThrough as CKRecordValue 530 } else { 531 record["readThrough"] = nil 532 } 533 if let sessionSnapshot { 534 record["sessionSnapshot"] = sessionSnapshot as CKRecordValue 535 } else { 536 record["sessionSnapshot"] = nil 537 } 538 // `timeLog` is the device-keyed solve-time log (encoded `TimeLog`): 539 // each device's active-play intervals plus its open session. Unlike the 540 // other LWW fields here, a device only ever mutates its own slot, so 541 // concurrent sibling writes converge by device once merged on apply. 542 if let timeLog, !timeLog.isEmpty { 543 record["timeLog"] = timeLog as CKRecordValue 544 } else { 545 record["timeLog"] = nil 546 } 547 if let pushAddress, !pushAddress.isEmpty { 548 record["pushAddress"] = pushAddress as CKRecordValue 549 } else { 550 record["pushAddress"] = nil 551 } 552 553 return record 554 } 555 556 /// Reads `readAt` off an inbound Player record — the per-account horizon 557 /// for collaborator moves this user has read or is actively watching. 558 /// Active puzzle sessions may lease the horizon into the near future and 559 /// close it with a lower current-time write, so callers must apply this 560 /// only after accepting the Player record under last-writer-wins 561 /// freshness checks. 562 /// Returns `nil` if the field is missing — older records, or a slot that 563 /// has not yet recorded a view. 564 static func parsePlayerReadAt(from record: CKRecord) -> Date? { 565 record["readAt"] as? Date 566 } 567 568 /// Reads `readThrough` off an inbound Player record — the per-account read 569 /// watermark: the latest other-author move time this user has actually 570 /// seen. Unlike `readAt` it is never leased into the future, so the 571 /// session-end push uses it to decide what a recipient still hasn't seen. 572 /// Returns `nil` for records that predate the field or a slot that has not 573 /// recorded a read yet. 574 static func parsePlayerReadThrough(from record: CKRecord) -> Date? { 575 record["readThrough"] as? Date 576 } 577 578 /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. These 579 /// fields carry the peer's cursor track start, not their exact local 580 /// reticle. Returns `nil` if any field is missing — the peer either hasn't 581 /// published a track yet or has cleared theirs (e.g. left the puzzle view). 582 static func parsePlayerSelection(from record: CKRecord) -> PlayerSelection? { 583 guard let row = record["selRow"] as? Int64, 584 let col = record["selCol"] as? Int64, 585 let dirRaw = record["selDir"] as? Int64, 586 let direction = PlayerSelection.Direction(rawValue: Int(dirRaw)) 587 else { return nil } 588 return PlayerSelection(row: Int(row), col: Int(col), direction: direction) 589 } 590 591 /// Reads `sessionSnapshot` off an inbound Player record — the encoded 592 /// `SeenBaseline` (this account's "last viewed" cutoff), written on leave. 593 /// Shared across the author's own devices so a sibling converges on the 594 /// latest view time rather than recomputing the catch-up baseline from its 595 /// own (possibly stale) view. Returns `nil` on older records or when the 596 /// account has not yet left a game with peers. (Pre-unification builds wrote 597 /// a per-peer Moves-snapshot map here, which simply fails to decode as a 598 /// `SeenBaseline` and is ignored — a per-device fallback.) 599 static func parsePlayerSessionSnapshot(from record: CKRecord) -> Data? { 600 record["sessionSnapshot"] as? Data 601 } 602 603 /// Reads `timeLog` off an inbound Player record — the encoded `TimeLog` 604 /// of device-keyed solve-time intervals. Returns `nil` for records that 605 /// predate the field (or before the schema deploy), which the clock treats 606 /// as a zero contribution. 607 static func parsePlayerTimeLog(from record: CKRecord) -> Data? { 608 record["timeLog"] as? Data 609 } 610 611 /// Reads `pushAddress` off an inbound Player record — the per-(account, 612 /// game) capability token a co-participant uses to address a push to this 613 /// player for this game. Possession is gated by the share ACL (only 614 /// participants can read the record), so the token, not an identity, is 615 /// the authorisation. Returns `nil` for older records or a slot that has 616 /// not yet minted one. 617 static func parsePlayerPushAddress(from record: CKRecord) -> String? { 618 record["pushAddress"] as? String 619 } 620 621 /// Parses an incoming `Player` record name back into its `(gameID, 622 /// authorID)` components. Returns `nil` if the name doesn't match the 623 /// `player-<UUID>-<authorID>` shape. 624 static func parsePlayerRecordName(_ name: String) -> (UUID, String)? { 625 guard name.hasPrefix("player-") else { return nil } 626 let rest = name.dropFirst("player-".count) 627 let uuidLength = 36 628 guard rest.count > uuidLength, 629 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 630 else { return nil } 631 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 632 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 633 let authorPart = String(rest.suffix(from: rest.index(rest.startIndex, offsetBy: uuidLength + 1))) 634 guard !authorPart.isEmpty else { return nil } 635 return (gameID, authorPart) 636 } 637 638 /// Parses an incoming `Moves` CKRecord into a `MovesValue`. Returns `nil` 639 /// if the record name doesn't match the `moves-<gameUUID>-<authorID>-<deviceID>` 640 /// shape or the cells payload fails to decode. 641 static func parseMovesRecord(_ record: CKRecord) -> MovesValue? { 642 guard record.recordType == "Moves" else { return nil } 643 guard let (gameID, authorID, deviceID) = parseMovesRecordName( 644 record.recordID.recordName 645 ) else { return nil } 646 guard let data = record["cells"] as? Data, 647 let cells = try? MovesCodec.decode(data) 648 else { return nil } 649 let updatedAt = record["updatedAt"] as? Date 650 ?? record.modificationDate 651 ?? Date() 652 return MovesValue( 653 gameID: gameID, 654 authorID: authorID, 655 deviceID: deviceID, 656 cells: cells, 657 updatedAt: updatedAt 658 ) 659 } 660 661 /// Parses `moves-<gameUUID>-<authorID>-<deviceID>` into its three parts. 662 /// `deviceID` is the suffix after the final `-`; `authorID` may itself 663 /// contain dashes (e.g. CloudKit user record names with no dashes today, 664 /// but we don't want to assume). 665 static func parseMovesRecordName(_ name: String) -> (UUID, String, String)? { 666 let prefix = "moves-" 667 guard name.hasPrefix(prefix) else { return nil } 668 let rest = name.dropFirst(prefix.count) 669 let uuidLength = 36 670 guard rest.count > uuidLength, 671 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 672 else { return nil } 673 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 674 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 675 let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) 676 let tail = rest[afterUUID...] 677 guard let lastDash = tail.lastIndex(of: "-") else { return nil } 678 let authorID = String(tail[tail.startIndex..<lastDash]) 679 let deviceID = String(tail[tail.index(after: lastDash)...]) 680 guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } 681 return (gameID, authorID, deviceID) 682 } 683 684 /// Parses `journal-<gameUUID>-<authorID>-<deviceID>` into its three parts. 685 /// Same decomposition as `parseMovesRecordName` (deviceID is the suffix 686 /// after the final `-`; authorID may itself contain dashes). 687 static func parseJournalRecordName(_ name: String) -> (UUID, String, String)? { 688 let prefix = "journal-" 689 guard name.hasPrefix(prefix) else { return nil } 690 let rest = name.dropFirst(prefix.count) 691 let uuidLength = 36 692 guard rest.count > uuidLength, 693 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 694 else { return nil } 695 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 696 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 697 let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) 698 let tail = rest[afterUUID...] 699 guard let lastDash = tail.lastIndex(of: "-") else { return nil } 700 let authorID = String(tail[tail.startIndex..<lastDash]) 701 let deviceID = String(tail[tail.index(after: lastDash)...]) 702 guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } 703 return (gameID, authorID, deviceID) 704 } 705 706 // MARK: - Applying incoming CKRecords to Core Data 707 708 /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if 709 /// none exists yet. Moves and Player records can arrive in a different 710 /// fetch batch than the Game record that created the zone — on 711 /// a fresh device CKSyncEngine paginates the initial pull and there is no 712 /// guarantee that Game comes first. Without this stub the parent lookup 713 /// fails, the inbound record is dropped, but CKSyncEngine still advances 714 /// its change token, so the gap is invisible until the next state reset. 715 /// The stub uses empty `title` / `puzzleSource` so `GameSummary.init?` 716 /// filters it out of the library until `applyGameRecord` arrives with 717 /// the real metadata and updates the same row (matched by `ckRecordName`). 718 static func ensureGameEntity( 719 forGameID gameID: UUID, 720 zoneID: CKRecordZone.ID, 721 in ctx: NSManagedObjectContext 722 ) -> GameEntity { 723 let name = recordName(forGameID: gameID) 724 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 725 req.predicate = NSPredicate(format: "ckRecordName == %@", name) 726 req.fetchLimit = 1 727 if let existing = try? ctx.fetch(req).first { return existing } 728 let entity = GameEntity(context: ctx) 729 entity.id = gameID 730 entity.ckRecordName = name 731 entity.ckZoneName = zoneID.zoneName 732 let ownerName = zoneID.ownerName 733 let isOwner = ownerName == CKCurrentUserDefaultName 734 entity.ckZoneOwnerName = isOwner ? nil : ownerName 735 entity.databaseScope = isOwner ? 0 : 1 736 entity.title = "" 737 entity.puzzleSource = "" 738 entity.createdAt = Date() 739 entity.updatedAt = Date() 740 return entity 741 } 742 743 static func applyGameRecord( 744 _ record: CKRecord, 745 to context: NSManagedObjectContext, 746 databaseScope: Int16 = 0, 747 onEngagementChange: ((UUID) -> Void)? = nil, 748 onCompletedTransition: ((UUID) -> Void)? = nil, 749 onContentKeyChange: ((UUID) -> Void)? = nil 750 ) -> GameEntity { 751 let recordName = record.recordID.recordName 752 let entity = fetchOrCreate( 753 entityName: "GameEntity", 754 recordName: recordName, 755 in: context 756 ) as! GameEntity 757 758 // Recover the UUID from the record name ("game-<UUID>") so the 759 // library query, which filters on `entity.id`, doesn't silently drop 760 // newly-synced games. 761 if entity.id == nil { 762 let uuidString = String(recordName.dropFirst("game-".count)) 763 entity.id = UUID(uuidString: uuidString) 764 } 765 766 // Drop fetched snapshots older than what we already have; adopting 767 // them downgrades the local etag and OpLock-fails the next save 768 // (same rationale as `applyMovesRecord` / `applyPlayerRecord`). 769 if entity.ckSystemFields != nil, 770 !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) { 771 return entity 772 } 773 774 // Always adopt the fresher etag and zone identity so the next outbound 775 // push uses a current change tag and routes to the right zone. 776 entity.ckRecordName = recordName 777 entity.ckSystemFields = encodeSystemFields(of: record) 778 entity.ckZoneName = record.recordID.zoneID.zoneName 779 let ownerName = record.recordID.zoneID.ownerName 780 entity.ckZoneOwnerName = ownerName == CKCurrentUserDefaultName ? nil : ownerName 781 entity.databaseScope = databaseScope 782 783 // Local mutable fields take precedence while a push is in flight. 784 // The flag is set atomically with the local write (in `markCompleted`, 785 // `resignGame`, `persistShareName`) and cleared once `SyncEngine` 786 // confirms the push landed. Writing server values here would clobber 787 // the pending change and the next outbound push would then serialise 788 // the clobbered value, permanently losing it server-side. 789 guard !entity.hasPendingSave else { return entity } 790 791 // Seed createdAt/updatedAt from the server record only on first sight, 792 // so a newly-arrived game has something for the library to order by. 793 // After that, the library timestamp tracks *gameplay* (Moves) alone. 794 // The Game record's modificationDate advances on non-gameplay writes 795 // too — engagement/push credentials, share metadata, the notification 796 // field — so adopting it on every fetch made a game look freshly 797 // "updated" when nothing was played (e.g. a peer, or this device, 798 // merely moved the cursor or re-minted a credential). Gameplay flows 799 // through the Moves path, which sets updatedAt independently; the 800 // winning move is itself a move, so completion still advances it. 801 if entity.createdAt == nil { 802 entity.createdAt = record.creationDate ?? Date() 803 } 804 if entity.updatedAt == nil { 805 entity.updatedAt = record.modificationDate ?? Date() 806 } 807 808 entity.title = record["title"] as? String ?? entity.title 809 // Capture the prior completion state before overwriting it: a 810 // not-completed → completed transition learned purely via sync (this 811 // device wasn't present when the puzzle was finished) does NOT run the 812 // local completion path, so it never uploads this device's journal. 813 // Replay's strict completeness would then wait on it forever. Signal 814 // the transition so the caller can enqueue the upload. 815 let wasCompleted = entity.completedAt != nil 816 entity.completedAt = record["completedAt"] as? Date 817 entity.completedBy = record["completedBy"] as? String 818 if !wasCompleted, entity.completedAt != nil, let id = entity.id { 819 onCompletedTransition?(id) 820 } 821 // Owner-side share marker — set on the device that created the share 822 // and round-tripped via the Game record so other owner-devices learn 823 // the game is shared. On participant devices `databaseScope == 1` 824 // already implies shared, but keeping the field in sync is harmless. 825 if let shareRecordName = record["shareRecordName"] as? String { 826 entity.ckShareRecordName = shareRecordName 827 } 828 829 // Adopt the engagement creds (skipped above while a local mint is 830 // still pushing, via the hasPendingSave guard). A change here is the 831 // signal a peer minted/rotated the room, so the receiver reconciles 832 // its live connection toward the new creds. 833 let incomingEngagement = record["engagement"] as? String 834 if entity.engagement != incomingEngagement { 835 entity.engagement = incomingEngagement 836 if let id = entity.id { onEngagementChange?(id) } 837 } 838 839 // Adopt the shared notification credentials. The credential is read 840 // lazily at registration/publish time, so converging the field is 841 // enough — but a change may carry a new embedded content key, so fire 842 // `onContentKeyChange` to re-mirror the App Group key directory the NSE 843 // reads. This is what lets a freshly-joined participant decrypt the 844 // first push it receives, rather than waiting for the next launch heal. 845 let incomingNotification = record["notification"] as? String 846 if entity.notification != incomingNotification { 847 entity.notification = incomingNotification 848 if let id = entity.id { onContentKeyChange?(id) } 849 } 850 851 if let asset = record["puzzleSource"] as? CKAsset, 852 let fileURL = asset.fileURL { 853 do { 854 let source = try String(contentsOf: fileURL, encoding: .utf8) 855 entity.puzzleSource = source 856 if let xd = try? XD.parse(source) { 857 let puzzle = Puzzle(xd: xd) 858 entity.puzzleCmVersion = Int64(XD.currentCmVersion) 859 // The title is always derived from the puzzle content (there 860 // is no custom game title), so trust the asset over the 861 // record's `title` field — which was already applied above. 862 // This re-derives the real title even when `record["title"]` 863 // carried a stale value, e.g. a participant's transient 864 // "Joining…" placeholder that a prior build wrote to the 865 // shared record, so the title self-heals on the next sync 866 // that carries the asset. 867 entity.title = puzzle.title 868 entity.populateCachedSummaryFields(from: puzzle) 869 } 870 } catch { 871 // CKSyncEngine has already committed this batch by the time 872 // the delegate returns, so re-throwing wouldn't redeliver. 873 // Surface the dropped puzzle source instead of silently 874 // leaving the entity without playable content. 875 let nsError = error as NSError 876 print( 877 "RecordSerializer: puzzleSource asset read failed for \(recordName) " + 878 "— domain=\(nsError.domain) code=\(nsError.code) " + 879 "\(nsError.localizedDescription)" 880 ) 881 } 882 } 883 884 return entity 885 } 886 887 /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight 888 /// off the record so any forward-compat fields the encoder added are 889 /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the 890 /// record is fresher. Returns `true` when cells/updatedAt were adopted, 891 /// `false` when the local-device-row guard short-circuited the body so 892 /// callers can skip the downstream grid refresh. 893 /// 894 /// `onNewAuthor` fires with the authorID when this record creates the first 895 /// `MovesEntity` row by a *remote* contributor — i.e. a participant the 896 /// roster can only discover from their moves (no `Player` record yet, see 897 /// `PlayerRoster.refresh`). Callers use it to trigger a one-off roster 898 /// refresh on a new collaborator's first move without refreshing on every 899 /// subsequent keystroke or sibling-device row. 900 @discardableResult 901 static func applyMovesRecord( 902 _ record: CKRecord, 903 value: MovesValue, 904 to ctx: NSManagedObjectContext, 905 localAuthorID: String? = nil, 906 onNewAuthor: ((String) -> Void)? = nil 907 ) -> Bool { 908 let ckName = record.recordID.recordName 909 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 910 req.predicate = NSPredicate(format: "ckRecordName == %@", ckName) 911 req.fetchLimit = 1 912 913 let entity: MovesEntity 914 let foundExisting: Bool 915 let authorAlreadyKnown: Bool 916 if let existing = try? ctx.fetch(req).first { 917 entity = existing 918 foundExisting = true 919 authorAlreadyKnown = true 920 } else { 921 let authorReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 922 authorReq.predicate = NSPredicate( 923 format: "game.id == %@ AND authorID == %@", 924 value.gameID as CVarArg, 925 value.authorID 926 ) 927 authorReq.fetchLimit = 1 928 authorAlreadyKnown = ((try? ctx.fetch(authorReq).first) != nil) 929 930 let game = ensureGameEntity( 931 forGameID: value.gameID, 932 zoneID: record.recordID.zoneID, 933 in: ctx 934 ) 935 entity = MovesEntity(context: ctx) 936 entity.game = game 937 foundExisting = false 938 } 939 940 // Drop fetched snapshots that are older than what we already have. 941 // The writeback after a successful push advances `ckSystemFields` to 942 // the latest server etag; a query that started before that push 943 // landed can return the prior server state, and adopting it here 944 // would downgrade the etag and OpLock-fail the next save. 945 if foundExisting, 946 !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) { 947 return false 948 } 949 950 // Adopt system fields so future saves target the server's current 951 // change tag. If this is our own per-device row and it already 952 // exists locally, the local value state is authoritative; tokenless 953 // push-driven direct fetches can re-deliver an older server copy while 954 // newer edits are still queued for upload. 955 entity.ckRecordName = ckName 956 entity.ckSystemFields = encodeSystemFields(of: record) 957 entity.authorID = value.authorID 958 entity.deviceID = value.deviceID 959 let isLocalDeviceRow = value.authorID == localAuthorID 960 && value.deviceID == localDeviceID 961 guard !foundExisting || !isLocalDeviceRow else { return false } 962 let previousUpdatedAt = entity.updatedAt ?? .distantPast 963 let previousCells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] 964 let mergedCells = mergeIncomingMovesCells( 965 existing: previousCells, 966 incoming: value.cells 967 ) 968 let mergedUpdatedAt = max( 969 previousUpdatedAt, 970 value.updatedAt, 971 mergedCells.values.map(\.updatedAt).max() ?? .distantPast 972 ) 973 974 entity.updatedAt = mergedUpdatedAt 975 entity.cells = (try? MovesCodec.encode(mergedCells)) ?? ((record["cells"] as? Data) ?? Data()) 976 977 if let game = entity.game, 978 game.updatedAt.map({ $0 < mergedUpdatedAt }) ?? true { 979 game.updatedAt = mergedUpdatedAt 980 } 981 // A newly-seen remote author is the roster's only cue to 982 // a contributor who hasn't published a `Player` record yet. Signal it 983 // once here; repeat moves and sibling-device rows by a known author 984 // don't. 985 if !foundExisting, 986 !authorAlreadyKnown, 987 value.authorID != localAuthorID, 988 value.authorID != CKCurrentUserDefaultName, 989 !value.authorID.isEmpty { 990 onNewAuthor?(value.authorID) 991 } 992 return true 993 } 994 995 private static func mergeIncomingMovesCells( 996 existing: [GridPosition: TimestampedCell], 997 incoming: [GridPosition: TimestampedCell] 998 ) -> [GridPosition: TimestampedCell] { 999 var cells = existing 1000 for (position, incomingCell) in incoming { 1001 if let existingCell = cells[position], 1002 existingCell.updatedAt > incomingCell.updatedAt { 1003 continue 1004 } 1005 cells[position] = incomingCell 1006 } 1007 return cells 1008 } 1009 1010 /// Projects an inbound `Decision` record onto local Core Data. For 1011 /// `kind == "block"` this upserts a `FriendEntity` tombstone keyed by the 1012 /// blocked author so the block becomes authoritative across the user's own 1013 /// devices: `applyInvitePings` (authorID-keyed) and the friendship 1014 /// bootstrap's `friendExists` (pairKey-keyed) both then suppress the 1015 /// blocked collaborator everywhere. A device that never befriended the 1016 /// author still gets a minimal blocked row. Returns `true` when a row was 1017 /// written. `localAuthorID` lets the pairKey be derived for the bootstrap 1018 /// short-circuit; it's deterministic from the unordered author pair. 1019 @discardableResult 1020 static func applyDecisionRecord( 1021 _ record: CKRecord, 1022 to ctx: NSManagedObjectContext, 1023 localAuthorID: String?, 1024 databaseScope: Int16 = 0 1025 ) -> Bool { 1026 guard record.recordType == "Decision" else { return false } 1027 // Identity comes from the record name (always present, immutable); 1028 // `kind` is also mirrored as a field, name-parse as the fallback. 1029 let parsed = parseDecisionRecordName(record.recordID.recordName) 1030 guard let kind = (record["kind"] as? String) ?? parsed?.kind, 1031 let key = parsed?.key, 1032 !kind.isEmpty, !key.isEmpty 1033 else { return false } 1034 1035 switch kind { 1036 case "block": 1037 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1038 req.predicate = NSPredicate(format: "authorID == %@", key) 1039 req.fetchLimit = 1 1040 let friend = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) 1041 friend.authorID = key 1042 friend.isBlocked = true 1043 if friend.pairKey == nil, 1044 let localAuthorID, !localAuthorID.isEmpty { 1045 friend.pairKey = FriendZone.pairKey(localAuthorID, key) 1046 } 1047 if friend.createdAt == nil { friend.createdAt = Date() } 1048 return true 1049 case "left": 1050 // The user left this shared game on another of their devices. 1051 // Hard-delete the local row so it stops hanging around (the 1052 // shared-zone deletion alone would only flag it access-revoked, 1053 // see SyncEngine.handleFetchedDatabaseChanges). Idempotent: a 1054 // re-applied decision after the row is gone is a no-op. Guarded 1055 // to participant rows (databaseScope == 1) so a same-id owned 1056 // copy is never collateral. 1057 guard let gameID = UUID(uuidString: key) else { return false } 1058 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1059 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1060 req.fetchLimit = 1 1061 guard let entity = try? ctx.fetch(req).first, 1062 entity.databaseScope == 1 else { return false } 1063 ctx.delete(entity) 1064 return true 1065 case nameDecisionKind: 1066 // A friend's display name, read out of the pairwise friend zone. 1067 // Our own copy (key == localAuthorID, account zone) carries no 1068 // Core Data projection — the local name lives in 1069 // `PlayerPreferences`; only its version is adopted, by the caller. 1070 guard let localAuthorID, !localAuthorID.isEmpty, 1071 key != localAuthorID, 1072 let name = record["payload"] as? String, 1073 !name.isEmpty 1074 else { return false } 1075 // Provenance: both participants can write into a friend zone, so 1076 // a name Decision is only honored when the zone is *the* pairwise 1077 // zone for (us, key) — the zone name embeds a hash of the author 1078 // pair, so the claimed subject is verifiable without trusting the 1079 // record. This also rejects a name Decision for a third party 1080 // misdelivered into an unrelated zone. 1081 let pairKey = FriendZone.pairKey(localAuthorID, key) 1082 let zoneID = record.recordID.zoneID 1083 guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else { 1084 return false 1085 } 1086 let version = decisionVersion(record) 1087 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1088 req.predicate = NSPredicate(format: "pairKey == %@", pairKey) 1089 req.fetchLimit = 1 1090 if let friend = try? ctx.fetch(req).first { 1091 guard !friend.isBlocked, 1092 version >= friend.displayNameVersion 1093 else { return false } 1094 friend.displayName = name 1095 friend.displayNameVersion = version 1096 return true 1097 } 1098 // No local row: the bootstrap evidence (game zones, `.friend` 1099 // Ping) may be long gone on a restored device, but the name 1100 // Decision arriving from a live friend zone is itself proof of 1101 // the friendship — resurrect the row from the zone it rode in on. 1102 let friend = FriendEntity(context: ctx) 1103 friend.authorID = key 1104 friend.pairKey = pairKey 1105 friend.friendZoneName = zoneID.zoneName 1106 friend.friendZoneOwnerName = zoneID.ownerName 1107 friend.databaseScope = databaseScope 1108 friend.createdAt = Date() 1109 friend.displayName = name 1110 friend.displayNameVersion = version 1111 return true 1112 case nicknameDecisionKind: 1113 // The user's private nickname for a friend, authoritative across 1114 // their own devices. Honored only from the account zone in our own 1115 // private database: friend zones are writable by the other 1116 // participant, who must not be able to relabel people in this 1117 // user's friends list. Match on zone *name* + private scope, not a 1118 // full `zoneID ==`: a record fetched back from CloudKit does not 1119 // reliably carry the `CKCurrentUserDefaultName` owner placeholder 1120 // `accountZoneID` is built with — its `ownerName` often comes back 1121 // as the concrete user-record ID, so an `==` silently rejects every 1122 // synced nickname. Scoping to the private DB keeps the anti-relabel 1123 // guarantee (a friend can only reach us through the shared DB). 1124 guard record.recordID.zoneID.zoneName == accountZoneID.zoneName, 1125 databaseScope == 0 1126 else { return false } 1127 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1128 req.predicate = NSPredicate(format: "authorID == %@", key) 1129 req.fetchLimit = 1 1130 // No resurrection: unlike a name Decision, an account-zone row 1131 // carries no zone provenance to rebuild a usable friendship from, 1132 // and a zoneless row would surface as an uninvitable friend. 1133 guard let friend = try? ctx.fetch(req).first else { return false } 1134 let version = decisionVersion(record) 1135 guard version >= friend.nicknameVersion else { return false } 1136 let nickname = (record["payload"] as? String)? 1137 .trimmingCharacters(in: .whitespacesAndNewlines) 1138 // Empty/absent payload is a deliberate clear, not a malformed 1139 // record — the rename alert's blank entry reverts to their name. 1140 friend.nickname = (nickname?.isEmpty == false) ? nickname : nil 1141 friend.nicknameVersion = version 1142 return true 1143 default: 1144 // Unknown kind from a newer build — ignore rather than guess. 1145 return false 1146 } 1147 } 1148 1149 // MARK: - System fields encode/decode 1150 1151 static func encodeSystemFields(of record: CKRecord) -> Data? { 1152 let coder = NSKeyedArchiver(requiringSecureCoding: true) 1153 record.encodeSystemFields(with: coder) 1154 coder.finishEncoding() 1155 return coder.encodedData 1156 } 1157 1158 static func decodeRecord(from data: Data) -> CKRecord? { 1159 guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } 1160 coder.requiresSecureCoding = true 1161 let record = CKRecord(coder: coder) 1162 coder.finishDecoding() 1163 return record 1164 } 1165 1166 /// Returns `true` when `incoming` reflects a server state at least as 1167 /// recent as the modification date encoded in `existingFields`. Used by 1168 /// the apply paths to drop fetched snapshots that arrive after our 1169 /// writeback has already adopted a newer change tag — adopting them 1170 /// would downgrade the local etag and the next save would OpLock-fail. 1171 /// Defaults to `true` when either side lacks a modification date so a 1172 /// first-time fetch can land. 1173 static func incomingIsAtLeastAsFresh( 1174 _ incoming: CKRecord, 1175 existingFields: Data? 1176 ) -> Bool { 1177 guard let existingFields, 1178 let existingRecord = decodeRecord(from: existingFields), 1179 let existingDate = existingRecord.modificationDate, 1180 let incomingDate = incoming.modificationDate 1181 else { return true } 1182 return incomingDate >= existingDate 1183 } 1184 1185 // MARK: - Private helpers 1186 1187 /// Restores a `CKRecord` from archived system fields (preserving the 1188 /// server change tag) or creates a fresh one if no archive is available. 1189 private static func restoreOrCreate( 1190 recordType: String, 1191 recordName: String, 1192 zone: CKRecordZone.ID, 1193 systemFields: Data? 1194 ) -> CKRecord { 1195 if let data = systemFields, let restored = decodeRecord(from: data) { 1196 return restored 1197 } 1198 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 1199 return CKRecord(recordType: recordType, recordID: recordID) 1200 } 1201 1202 private static func fetchOrCreate( 1203 entityName: String, 1204 recordName: String, 1205 in context: NSManagedObjectContext 1206 ) -> NSManagedObject { 1207 let request = NSFetchRequest<NSManagedObject>(entityName: entityName) 1208 request.predicate = NSPredicate(format: "ckRecordName == %@", recordName) 1209 request.fetchLimit = 1 1210 if let existing = try? context.fetch(request).first { 1211 return existing 1212 } 1213 return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context) 1214 } 1215 1216 }