RecordSerializer.swift (78687B)
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 "roomCredential", 18 "pushCredential", 19 "puzzleSource", 20 "syncVersion", 21 ] 22 23 // authorID/deviceID are not stored as fields — they're recovered from the 24 // record name (`moves-<gameID>-<authorID>-<deviceID>`). 25 static let movesDesiredKeys: [CKRecord.FieldKey] = [ 26 "cells", 27 "updatedAt", 28 ] 29 30 // authorID is recovered from the record name (`player-<gameID>-<authorID>`). 31 static let playerDesiredKeys: [CKRecord.FieldKey] = [ 32 "name", 33 "updatedAt", 34 "selRow", 35 "selCol", 36 "selDir", 37 "presenceUntil", 38 "readThrough", 39 "viewedAt", 40 "timeLog", 41 "pushAddress", 42 ] 43 44 // authorID/deviceID are recovered from the record name. 45 static let pingDesiredKeys: [CKRecord.FieldKey] = [ 46 "playerName", 47 "puzzleTitle", 48 "kind", 49 "payload", 50 "addressee", 51 ] 52 53 static let pingDeletionDesiredKeys: [CKRecord.FieldKey] = [ 54 "kind", 55 ] 56 57 // MARK: - Device identity 58 59 /// A stable per-device identifier appended to move and snapshot record 60 /// names to prevent two devices owned by the same iCloud user from 61 /// producing identical record names when both assign the same Lamport 62 /// clock value while offline. 63 /// 64 /// Stored in UserDefaults so it survives app restarts but resets on 65 /// reinstall (which is fine — a reinstalled app has no local moves to 66 /// conflict with). 67 static let localDeviceID: String = { 68 let key = "crossmate.localDeviceID" 69 if let stored = UserDefaults.standard.string(forKey: key) { 70 return stored 71 } 72 let new = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() 73 UserDefaults.standard.set(new, forKey: key) 74 return new 75 }() 76 77 // MARK: - Record names 78 79 static func recordName(forGameID gameID: UUID) -> String { 80 "game-\(gameID.uuidString)" 81 } 82 83 /// Recovers the game UUID from a `"game-<UUID>"` record or zone name — the 84 /// inverse of `recordName(forGameID:)`. Returns nil when the name isn't a 85 /// game name or the UUID doesn't parse. A game's zone name and its root 86 /// record name are identical, so this also resolves a share's zone. 87 static func gameID(fromGameRecordName name: String) -> UUID? { 88 guard name.hasPrefix("game-") else { return nil } 89 return UUID(uuidString: String(name.dropFirst("game-".count))) 90 } 91 92 /// One Moves record per `(game, authorID, deviceID)`. Each device only 93 /// writes to its own slot, so there are no write-write conflicts on the 94 /// `cells` field. 95 static func recordName( 96 forMovesInGame gameID: UUID, 97 authorID: String, 98 deviceID: String 99 ) -> String { 100 "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)" 101 } 102 103 /// One Journal record per `(game, authorID, deviceID)` — this device's 104 /// whole local move log, uploaded once at completion (Phase 2). Same 105 /// `(game, author, device)` shape as the Moves record so collaborators' 106 /// uploads stay distinct and mergeable by timestamp for replay. 107 static func recordName( 108 forJournalInGame gameID: UUID, 109 authorID: String, 110 deviceID: String 111 ) -> String { 112 "journal-\(gameID.uuidString)-\(authorID)-\(deviceID)" 113 } 114 115 /// One player record per (game, author). Each participant only ever 116 /// writes to their own slot, so there are no write-write conflicts on 117 /// the field. 118 static func recordName(forPlayerInGame gameID: UUID, authorID: String) -> String { 119 "player-\(gameID.uuidString)-\(authorID)" 120 } 121 122 /// One Ping record per event. `deviceID` keeps cross-device writes from 123 /// the same iCloud user unique (authorID is identical across that user's 124 /// devices), and the event timestamp covers repeated pings from the same 125 /// device. 126 static func recordName( 127 forPingInGame gameID: UUID, 128 authorID: String, 129 deviceID: String, 130 eventTimestampMs: Int64 131 ) -> String { 132 "ping-\(gameID.uuidString)-\(authorID)-\(deviceID)-\(eventTimestampMs)" 133 } 134 135 /// One `Decision` record per `(kind, key)`. A durable, per-user fact that 136 /// must agree across a single iCloud user's own devices — the durable 137 /// counterpart to the transient `Ping`. Lives in the account zone; the 138 /// deterministic name makes every write an idempotent upsert. `kind` 139 /// carries no dashes (so the first dash after the prefix splits cleanly); 140 /// `key` may contain dashes. 141 static func decisionRecordName(kind: String, key: String) -> String { 142 "decision-\(kind)-\(key)" 143 } 144 145 /// Parses `decision-<kind>-<key>`. `kind` is the segment up to the first 146 /// dash after the prefix; `key` is the remainder. 147 static func parseDecisionRecordName(_ name: String) -> (kind: String, key: String)? { 148 let prefix = "decision-" 149 guard name.hasPrefix(prefix) else { return nil } 150 let rest = name.dropFirst(prefix.count) 151 guard let dash = rest.firstIndex(of: "-") else { return nil } 152 let kind = String(rest[rest.startIndex..<dash]) 153 let key = String(rest[rest.index(after: dash)...]) 154 guard !kind.isEmpty, !key.isEmpty else { return nil } 155 return (kind, key) 156 } 157 158 /// Kind for the display-name Decision: `decision-name-<authorID>`, payload 159 /// = the display name, `version` = the author's monotonic rename 160 /// generation. The author writes their own copy into their account zone 161 /// (own-device convergence and restore durability) and into every friend 162 /// zone they participate in (the friend's devices read it from there) — 163 /// names never sync through any other channel. 164 static let nameDecisionKind = "name" 165 166 static func nameDecisionName(authorID: String) -> String { 167 decisionRecordName(kind: nameDecisionKind, key: authorID) 168 } 169 170 /// Parses a display-name Decision into its subject author, name, and 171 /// version. Returns `nil` for any other decision or an empty payload. 172 static func parseNameDecision( 173 _ record: CKRecord 174 ) -> (authorID: String, name: String, version: Int64)? { 175 guard record.recordType == "Decision", 176 let (kind, key) = parseDecisionRecordName(record.recordID.recordName), 177 kind == nameDecisionKind, 178 ((record["kind"] as? String) ?? nameDecisionKind) == nameDecisionKind, 179 let name = record["payload"] as? String, 180 !name.isEmpty 181 else { return nil } 182 return (key, name, decisionVersion(record)) 183 } 184 185 /// Kind for the friend-nickname Decision: `decision-nickname-<authorID>`, 186 /// payload = the nickname this user privately calls that friend (absent or 187 /// empty = cleared, fall back to the friend's own name), `version` = this 188 /// user's monotonic rename generation for that friend. Lives only in the 189 /// account zone — it's the user's own label, never shared with the friend. 190 static let nicknameDecisionKind = "nickname" 191 static let encryptionKeyDecisionKind = "encryptionKey" 192 193 /// Kind for the friend-block Decision: `decision-block-<authorID>`, payload 194 /// `"1"` blocked / `"0"` unblocked, `version` = this user's monotonic block 195 /// generation for that friend. Lives only in the account zone and converges 196 /// the *UI* flag across the user's own devices — the actual enforcement is 197 /// the server-side `.readOnly` downgrade on the inbox share, which is 198 /// inherently account-wide. 199 static let blockDecisionKind = "block" 200 201 static let accountDecisionKind = "account" 202 static let accountPushAddressDecisionKey = "pushAddress" 203 /// Key for the account-wide push *secret* decision. The secret is the HMAC 204 /// key from which every per-game push address is derived (see 205 /// `deriveGameAddress`); it converges across the account's own devices the 206 /// same way the account address does, and is never sent to peers or the 207 /// push worker — only the derived per-game addresses are. 208 static let accountPushSecretDecisionKey = "pushSecret" 209 210 static var accountPushAddressDecisionName: String { 211 decisionRecordName(kind: accountDecisionKind, key: accountPushAddressDecisionKey) 212 } 213 214 static var accountPushSecretDecisionName: String { 215 decisionRecordName(kind: accountDecisionKind, key: accountPushSecretDecisionKey) 216 } 217 218 /// A push-address/secret Decision is authoritative only from *this 219 /// account's* zone in the private database. Match on zone name + private 220 /// scope, mirroring the `nickname` case in `applyDecisionRecord`: a friend 221 /// holds `.readWrite` on our private `friend-<pairKey>` inbox (also scope 222 /// 0), so without the zone-name check a forged `decision-account-pushSecret` 223 /// dropped there would be adopted as our own HMAC secret and re-derive every 224 /// per-game push address. An `==` on the full `zoneID` is unreliable — a 225 /// record fetched back from CloudKit often loses the 226 /// `CKCurrentUserDefaultName` owner placeholder — so match the name. 227 static func isAccountZonePrivateDecision( 228 _ record: CKRecord, 229 databaseScope: DatabaseScope 230 ) -> Bool { 231 record.recordID.zoneID.zoneName == accountZoneID.zoneName 232 && databaseScope == .private 233 } 234 235 static func parseAccountPushAddressDecision( 236 _ record: CKRecord, 237 databaseScope: DatabaseScope 238 ) -> String? { 239 guard record.recordType == "Decision", 240 record.recordID.recordName == accountPushAddressDecisionName, 241 (record["kind"] as? String) == accountDecisionKind, 242 isAccountZonePrivateDecision(record, databaseScope: databaseScope), 243 let address = record["payload"] as? String, 244 !address.isEmpty 245 else { return nil } 246 return address 247 } 248 249 /// Default generation for a `version`-less Decision — any record written by 250 /// the pre-rotation code. Matched to the value a fresh mint uses so legacy 251 /// and freshly-minted secrets share a generation and converge via the 252 /// equal-version "server wins" rule, while a deliberate rotation (2+) 253 /// supersedes them. Mapping to 0 instead would let the first post-update 254 /// mint clobber an already-converged legacy secret. 255 static let decisionBaseVersion: Int64 = 1 256 257 /// The monotonic generation of a Decision. Higher wins: an inbound or 258 /// conflicting record at a higher version supersedes the local value; equal 259 /// versions converge on whoever reached the server first. Absent (legacy) 260 /// records report `decisionBaseVersion`. 261 static func decisionVersion(_ record: CKRecord) -> Int64 { 262 (record["version"] as? Int64) ?? decisionBaseVersion 263 } 264 265 static func parseAccountPushSecretDecision( 266 _ record: CKRecord, 267 databaseScope: DatabaseScope 268 ) -> (secret: String, version: Int64)? { 269 guard record.recordType == "Decision", 270 record.recordID.recordName == accountPushSecretDecisionName, 271 (record["kind"] as? String) == accountDecisionKind, 272 isAccountZonePrivateDecision(record, databaseScope: databaseScope), 273 let secret = record["payload"] as? String, 274 !secret.isEmpty 275 else { return nil } 276 return (secret, decisionVersion(record)) 277 } 278 279 /// Derives this account's push address for one game as 280 /// `HMAC-SHA256(secret, gameID)`, base64url-encoded. Deterministic, so every 281 /// one of the account's devices computes the identical address for a game 282 /// without any negotiation, and per-game scoped: a peer holding one game's 283 /// address can't compute another's without the secret, which never leaves 284 /// the account's devices. Rotation is by changing the secret. 285 static func deriveGameAddress(secret: String, gameID: UUID) -> String { 286 let key = SymmetricKey(data: Data(secret.utf8)) 287 let mac = HMAC<SHA256>.authenticationCode( 288 for: Data(gameID.uuidString.utf8), 289 using: key 290 ) 291 return Data(mac).base64EncodedString() 292 .replacingOccurrences(of: "+", with: "-") 293 .replacingOccurrences(of: "/", with: "_") 294 .replacingOccurrences(of: "=", with: "") 295 } 296 297 // MARK: - Zone 298 299 /// Zone ID for a per-game zone. `ownerName` defaults to the current user 300 /// placeholder; pass an explicit value for shared games where the zone is 301 /// owned by another iCloud account. 302 static func zoneID( 303 for gameID: UUID, 304 ownerName: String = CKCurrentUserDefaultName 305 ) -> CKRecordZone.ID { 306 CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName) 307 } 308 309 // MARK: - Inbound record identity 310 311 /// The record types that live in per-game zones (plus the archive zone) 312 /// and therefore must pass `isTrustedGameScopedRecord` / 313 /// `isTrustedGameScopedDeletion` before touching a game-scoped apply 314 /// path. Everything else (Decision, share metadata, …) has its own 315 /// zone/scope gates. 316 static func isGameScopedRecordType(_ type: CKRecord.RecordType) -> Bool { 317 switch type { 318 case "Game", "Moves", "Player", "Ping", "Journal", 319 Archive.recordType, Archive.legacyRecordType: 320 return true 321 default: 322 return false 323 } 324 } 325 326 /// CloudKit reports the creator of a record that the *fetching* user 327 /// created themselves as the `CKCurrentUserDefaultName` placeholder, not 328 /// their concrete user-record name — so the placeholder must be accepted 329 /// alongside the claimed author, or every self-authored record is 330 /// rejected when it round-trips (fresh install, second device, replay). 331 /// This does not weaken the impersonation defense: a record created by a 332 /// *remote* participant always arrives with their concrete creator ID, 333 /// never the placeholder. 334 private static func creatorMatches( 335 _ creatorUserRecordName: String?, 336 claimedAuthorID: String 337 ) -> Bool { 338 creatorUserRecordName == claimedAuthorID 339 || creatorUserRecordName == CKCurrentUserDefaultName 340 } 341 342 /// Returns whether a fetched record is safe to route into a game-scoped 343 /// apply path. Record names are writable by share participants, so they 344 /// are only an assertion until they agree with CloudKit's zone identity. 345 /// 346 /// Moves, Player, Journal, and Ping records additionally claim an author 347 /// in their name. CloudKit supplies the immutable creator identity for a 348 /// record; require it to match so a collaborator cannot create a new row 349 /// in their own name space that impersonates another participant. `Game` 350 /// has no claimed author — its root record is authenticated by requiring 351 /// its record name to be exactly the per-game zone name. 352 static func isTrustedGameScopedRecord(_ record: CKRecord) -> Bool { 353 isTrustedGameScopedRecord( 354 record, 355 creatorUserRecordName: record.creatorUserRecordID?.recordName 356 ) 357 } 358 359 /// Variant used by tests to exercise CloudKit provenance without needing 360 /// a server-created CKRecord. 361 static func isTrustedGameScopedRecord( 362 _ record: CKRecord, 363 creatorUserRecordName: String? 364 ) -> Bool { 365 let recordID = record.recordID 366 switch record.recordType { 367 case "Game": 368 guard let gameID = gameID(fromGameRecordName: recordID.recordName) else { 369 return false 370 } 371 return recordID.zoneID.zoneName == recordName(forGameID: gameID) 372 373 case "Moves": 374 guard let (gameID, authorID, _) = parseMovesRecordName(recordID.recordName) else { 375 return false 376 } 377 return recordID.zoneID.zoneName == recordName(forGameID: gameID) 378 && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID) 379 380 case "Player": 381 guard let (gameID, authorID) = parsePlayerRecordName(recordID.recordName) else { 382 return false 383 } 384 return recordID.zoneID.zoneName == recordName(forGameID: gameID) 385 && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID) 386 387 case "Journal": 388 guard let (gameID, authorID, _) = parseJournalRecordName(recordID.recordName) else { 389 return false 390 } 391 return recordID.zoneID.zoneName == recordName(forGameID: gameID) 392 && creatorMatches(creatorUserRecordName, claimedAuthorID: authorID) 393 394 case "Ping": 395 guard let (gameID, authorID, _) = parsePingRecordName(recordID.recordName), 396 creatorMatches(creatorUserRecordName, claimedAuthorID: authorID) 397 else { return false } 398 // Friend-zone pings deliberately carry the target game's ID while 399 // living in a deterministic friend mailbox. Game-zone pings must 400 // still be confined to the game named by their record. 401 return !recordID.zoneID.zoneName.hasPrefix("game-") 402 || recordID.zoneID.zoneName == recordName(forGameID: gameID) 403 404 case Archive.recordType: 405 guard let gameID = Archive.originalGameID(fromName: recordID.recordName), 406 recordID.recordName == Archive.recordName(forOriginalGameID: gameID), 407 recordID.zoneID.zoneName == Archive.zoneName 408 else { return false } 409 return true 410 411 case Archive.legacyRecordType: 412 guard let gameID = Archive.originalGameID(fromName: recordID.recordName), 413 recordID.recordName == Archive.legacyRecordName( 414 forOriginalGameID: gameID 415 ), 416 recordID.zoneID.zoneName == Archive.legacyZoneID( 417 forOriginalGameID: gameID 418 ).zoneName 419 else { return false } 420 return true 421 422 default: 423 return false 424 } 425 } 426 427 /// Deletions do not carry creator metadata, but CloudKit does retain their 428 /// full record identity. Reject a deletion whose name is not confined to 429 /// the game zone before it can match a local row. Only consulted for 430 /// `isGameScopedRecordType` types; anything else is out of scope here. 431 static func isTrustedGameScopedDeletion( 432 recordID: CKRecord.ID, 433 recordType: CKRecord.RecordType 434 ) -> Bool { 435 let gameID: UUID? 436 switch recordType { 437 case "Game": 438 gameID = Self.gameID(fromGameRecordName: recordID.recordName) 439 case "Moves": 440 gameID = parseMovesRecordName(recordID.recordName)?.0 441 case "Player": 442 gameID = parsePlayerRecordName(recordID.recordName)?.0 443 case "Journal": 444 gameID = parseJournalRecordName(recordID.recordName)?.0 445 case "Ping": 446 // Same confinement rule as the record case: a friend-mailbox ping 447 // names a game outside its zone by design; a game-zone ping must 448 // name its own zone's game. 449 guard let (pingGameID, _, _) = parsePingRecordName(recordID.recordName) else { 450 return false 451 } 452 return !recordID.zoneID.zoneName.hasPrefix("game-") 453 || recordID.zoneID.zoneName == recordName(forGameID: pingGameID) 454 case Archive.recordType: 455 guard let originalID = Archive.originalGameID(fromName: recordID.recordName) else { 456 return false 457 } 458 return recordID.recordName == Archive.recordName(forOriginalGameID: originalID) 459 && recordID.zoneID.zoneName == Archive.zoneName 460 case Archive.legacyRecordType: 461 guard let originalID = Archive.originalGameID(fromName: recordID.recordName) else { 462 return false 463 } 464 return recordID.recordName == Archive.legacyRecordName( 465 forOriginalGameID: originalID 466 ) && recordID.zoneID.zoneName == Archive.legacyZoneID( 467 forOriginalGameID: originalID 468 ).zoneName 469 default: 470 return false 471 } 472 guard let gameID else { return false } 473 return recordID.zoneID.zoneName == recordName(forGameID: gameID) 474 } 475 476 /// Matches the local row for a record by its full zone identity, so a 477 /// private-zone record can never match a same-named shared-zone row (or 478 /// vice versa, or two shares of the same game by different owners). 479 /// 480 /// The owner name participates only for shared-database zones. CloudKit 481 /// does not reliably round-trip the `CKCurrentUserDefaultName` placeholder 482 /// for the current user's own zones — a fetched zone ID often carries the 483 /// concrete user-record ID instead (see `isAccountZonePrivateDecision`) — 484 /// so private rows always store `nil` and match on zone name alone, which 485 /// is unambiguous there: every private-database zone belongs to the 486 /// current user. 487 static func gameIdentityPredicate( 488 recordName: String, 489 zoneID: CKRecordZone.ID, 490 databaseScope: DatabaseScope, 491 entityPrefix: String = "" 492 ) -> NSPredicate { 493 let zonePrefix = entityPrefix.isEmpty ? "" : "\(entityPrefix)." 494 if databaseScope == .private { 495 return NSPredicate( 496 format: "ckRecordName == %@ AND \(zonePrefix)ckZoneName == %@ AND \(zonePrefix)ckZoneOwnerName == NIL", 497 recordName, 498 zoneID.zoneName 499 ) 500 } 501 return NSPredicate( 502 format: "ckRecordName == %@ AND \(zonePrefix)ckZoneName == %@ AND \(zonePrefix)ckZoneOwnerName == %@", 503 recordName, 504 zoneID.zoneName, 505 zoneID.ownerName 506 ) 507 } 508 509 /// Zone ID for the user's account-wide zone in the private database. Holds 510 /// records that coordinate state between a single iCloud user's own 511 /// devices — never shared with collaborators, since the private database 512 /// itself isn't reachable to anyone else. 513 static let accountZoneID = CKRecordZone.ID( 514 zoneName: "account", 515 ownerName: CKCurrentUserDefaultName 516 ) 517 518 // MARK: - Moves record building 519 520 static func movesRecord( 521 from view: MovesValue, 522 zone: CKRecordZone.ID, 523 systemFields: Data? 524 ) throws -> CKRecord { 525 let movesName = recordName( 526 forMovesInGame: view.gameID, 527 authorID: view.authorID, 528 deviceID: view.deviceID 529 ) 530 let record = restoreOrCreate( 531 recordType: "Moves", 532 recordName: movesName, 533 zone: zone, 534 systemFields: systemFields 535 ) 536 537 // authorID/deviceID live in the record name, not as fields. 538 record["updatedAt"] = view.updatedAt as CKRecordValue 539 record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue 540 541 return record 542 } 543 544 // MARK: - Journal record building 545 546 /// Builds the `Journal` record carrying this device's full move log as a 547 /// `CKAsset`. Write-once at completion, so there is no system-fields 548 /// archive (mirrors `Ping`/`Decision`): a fresh record each build, and a 549 /// re-send of an already-uploaded journal is a benign conflict the send 550 /// path drops. The encoded entries are written to a temp file the same way 551 /// `populateGameRecord` stages `puzzleSource` — CloudKit copies the asset 552 /// on upload and the OS reaps the temporary directory. 553 static func journalRecord( 554 gameID: UUID, 555 authorID: String, 556 deviceID: String, 557 updatedAt: Date, 558 entries: [JournalValue], 559 zone: CKRecordZone.ID 560 ) throws -> CKRecord { 561 let name = recordName(forJournalInGame: gameID, authorID: authorID, deviceID: deviceID) 562 let recordID = CKRecord.ID(recordName: name, zoneID: zone) 563 let record = CKRecord(recordType: "Journal", recordID: recordID) 564 // authorID/deviceID live in the record name, not as fields. 565 record["updatedAt"] = updatedAt as CKRecordValue 566 567 let data = try JournalCodec.encode(entries) 568 let url = FileManager.default.temporaryDirectory 569 .appendingPathComponent(UUID().uuidString) 570 .appendingPathExtension("json") 571 try data.write(to: url, options: .atomic) 572 record["entries"] = CKAsset(fileURL: url) 573 574 return record 575 } 576 577 static func gameRecord( 578 from entity: GameEntity, 579 recordID: CKRecord.ID, 580 includePuzzleSource: Bool 581 ) -> CKRecord? { 582 guard entity.ckRecordName != nil else { return nil } 583 let record: CKRecord 584 if let fields = entity.ckSystemFields, 585 let restored = decodeRecord(from: fields) { 586 record = restored 587 } else { 588 record = CKRecord(recordType: "Game", recordID: recordID) 589 } 590 populateGameRecord(record, from: entity, includePuzzleSource: includePuzzleSource) 591 return record 592 } 593 594 static func populateGameRecord( 595 _ record: CKRecord, 596 from entity: GameEntity, 597 includePuzzleSource: Bool 598 ) { 599 // `title`, `shareRecordName`, and `syncVersion` are owner-authoritative: 600 // the title and protocol come from the game the owner created, and only 601 // owner devices track the share record. A participant only re-saves this record to 602 // mint the engagement/notification creds below, and at join time its 603 // local `title` is still the transient "Joining…" placeholder 604 // (`SyncEngine.handleFetchedDatabaseChanges`) until the owner's Game 605 // record lands. Writing it from a participant would LWW-clobber the real 606 // title on the shared record for everyone — so a non-owner leaves these 607 // fields untouched and the server keeps the owner's value. 608 let isOwner = entity.databaseScope == 0 609 if isOwner { 610 record["title"] = entity.title as CKRecordValue? 611 // Owner-side share marker. Propagated so other owner-devices can flip 612 // their `isShared` flag without reading the zone's CKShare directly. 613 record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue? 614 record["syncVersion"] = GameSyncVersion.normalized(entity.syncVersion) as CKRecordValue 615 } 616 // Completion is terminal. A metadata-only save from a participant with 617 // a stale local row must not clear a completion already present in the 618 // restored server record. 619 if let completedAt = entity.completedAt { 620 record["completedAt"] = completedAt as CKRecordValue 621 // Solver's authorID on a win; nil for a resignation. 622 record["completedBy"] = entity.completedBy as CKRecordValue? 623 } 624 // The shared live-engagement room credentials (encoded 625 // EngagementRoomCredentials). Any present participant may mint these 626 // when the field is empty; convergence is plain record-level LWW, and 627 // peers connect to whatever creds the field currently holds. 628 // Stored as UTF-8 BYTES (matching cells/timeLog), not STRING — these 629 // are opaque JSON blobs, not queryable text. 630 record["roomCredential"] = entity.engagement?.data(using: .utf8) as CKRecordValue? 631 // The shared per-game notification credentials (encoded 632 // GamePushCredentials: the push auth secret + credID, plus the 633 // worker-blind content key the payload is encrypted under). Synced to 634 // participants like `engagement`; any participant may mint it when 635 // empty, and record-level LWW converges concurrent mints. 636 record["pushCredential"] = entity.notification?.data(using: .utf8) as CKRecordValue? 637 guard includePuzzleSource, let source = entity.puzzleSource else { return } 638 let url = FileManager.default.temporaryDirectory 639 .appendingPathComponent(UUID().uuidString) 640 .appendingPathExtension("xd") 641 try? source.write(to: url, atomically: true, encoding: .utf8) 642 record["puzzleSource"] = CKAsset(fileURL: url) 643 } 644 645 /// Builds a freshly-minted Ping record. Pings are write-once — they have 646 /// no Core Data equivalent and no system-fields archive. 647 /// - `authorID` + `deviceID` together let receivers filter out self-sends. 648 /// authorID alone is insufficient for kinds (e.g. `.opened`) that fire 649 /// between a single user's own devices, where authorID is identical. 650 /// - `playerName` and `puzzleTitle` let receivers render the alert body. 651 /// - `kind` distinguishes the bootstrap kinds current clients write 652 /// (.friend / .invite / .decline); legacy `.join`/`.hail` records 653 /// remain parseable but are no longer produced. 654 static func pingRecord( 655 gameID: UUID, 656 authorID: String, 657 deviceID: String, 658 playerName: String, 659 puzzleTitle: String, 660 eventTimestampMs: Int64, 661 kind: PingKind, 662 payload: String? = nil, 663 addressee: String? = nil, 664 zone: CKRecordZone.ID 665 ) -> CKRecord { 666 let name = recordName( 667 forPingInGame: gameID, 668 authorID: authorID, 669 deviceID: deviceID, 670 eventTimestampMs: eventTimestampMs 671 ) 672 let recordID = CKRecord.ID(recordName: name, zoneID: zone) 673 let record = CKRecord(recordType: "Ping", recordID: recordID) 674 // authorID/deviceID live in the record name, not as fields. 675 record["playerName"] = playerName as CKRecordValue 676 record["puzzleTitle"] = puzzleTitle as CKRecordValue 677 record["kind"] = kind.rawValue as CKRecordValue 678 // Directed pings target one player by authorID; nil ⇒ broadcast (every 679 // recipient acts on it). 680 if let addressee { 681 record["addressee"] = addressee as CKRecordValue 682 } 683 if let payload { 684 record["payload"] = payload as CKRecordValue 685 } 686 return record 687 } 688 689 /// Builds a `Decision` record. The identity (`kind` + `key`) lives in the 690 /// record name, which keeps every write an idempotent upsert; `key` is not 691 /// duplicated as a field. `payload` is the generic, kind-specific extra 692 /// slot — empty for `block`, where presence alone is the fact — mirroring 693 /// `Ping.payload`. `systemFields` is the archived server record (with its 694 /// change tag): pass it so a re-send carries the current tag and CloudKit 695 /// accepts the update (e.g. rotating the push secret) instead of rejecting 696 /// it as a colliding create. Decisions are therefore upsertable, not 697 /// write-once; a payload-less write clears any value a restored record held. 698 static func decisionRecord( 699 kind: String, 700 key: String, 701 payload: String? = nil, 702 zone: CKRecordZone.ID, 703 systemFields: Data? = nil, 704 version: Int64? = nil 705 ) -> CKRecord { 706 let name = decisionRecordName(kind: kind, key: key) 707 let record = restoreOrCreate( 708 recordType: "Decision", 709 recordName: name, 710 zone: zone, 711 systemFields: systemFields 712 ) 713 record["kind"] = kind as CKRecordValue 714 record["payload"] = payload.map { $0 as CKRecordValue } 715 record["version"] = version.map { $0 as CKRecordValue } 716 return record 717 } 718 719 static func playerRecord( 720 gameID: UUID, 721 authorID: String, 722 name: String, 723 updatedAt: Date, 724 selection: PlayerSelection?, 725 presenceUntil: Date? = nil, 726 readThrough: Date? = nil, 727 viewedAt: Date? = nil, 728 timeLog: Data? = nil, 729 pushAddress: String? = nil, 730 zone: CKRecordZone.ID, 731 systemFields: Data? 732 ) -> CKRecord { 733 let recordName = recordName(forPlayerInGame: gameID, authorID: authorID) 734 let record = restoreOrCreate( 735 recordType: "Player", 736 recordName: recordName, 737 zone: zone, 738 systemFields: systemFields 739 ) 740 741 // authorID lives in the record name, not as a field. 742 record["name"] = name as CKRecordValue 743 record["updatedAt"] = updatedAt as CKRecordValue 744 if let selection { 745 record["selRow"] = Int64(selection.row) as CKRecordValue 746 record["selCol"] = Int64(selection.col) as CKRecordValue 747 record["selDir"] = Int64(selection.direction.rawValue) as CKRecordValue 748 } else { 749 record["selRow"] = nil 750 record["selCol"] = nil 751 record["selDir"] = nil 752 } 753 // The forward-dated presence lease, shipped as the `presenceUntil` 754 // CKRecord field. 755 if let presenceUntil { 756 record["presenceUntil"] = presenceUntil as CKRecordValue 757 } else { 758 record["presenceUntil"] = nil 759 } 760 // `readThrough` is the true read watermark — the latest other-author 761 // move time this account has actually seen. Never forward-dated, so a 762 // peer's session-end summary windows only on moves we genuinely missed. 763 if let readThrough { 764 record["readThrough"] = readThrough as CKRecordValue 765 } else { 766 record["readThrough"] = nil 767 } 768 if let viewedAt { 769 record["viewedAt"] = viewedAt as CKRecordValue 770 } else { 771 record["viewedAt"] = nil 772 } 773 // `timeLog` is the device-keyed solve-time log (encoded `TimeLog`): 774 // each device's active-play intervals plus its open session. Unlike the 775 // other LWW fields here, a device only ever mutates its own slot, so 776 // concurrent sibling writes converge by device once merged on apply. 777 if let timeLog, !timeLog.isEmpty { 778 record["timeLog"] = timeLog as CKRecordValue 779 } else { 780 record["timeLog"] = nil 781 } 782 if let pushAddress, !pushAddress.isEmpty { 783 record["pushAddress"] = pushAddress as CKRecordValue 784 } else { 785 record["pushAddress"] = nil 786 } 787 788 return record 789 } 790 791 /// Reads `presenceUntil` off an inbound Player record — the per-account horizon 792 /// for collaborator moves this user has read or is actively watching. 793 /// Active puzzle sessions may lease the horizon into the near future and 794 /// close it with a lower current-time write, so callers must apply this 795 /// only after accepting the Player record under last-writer-wins 796 /// freshness checks. 797 /// Returns `nil` if the field is missing — older records, or a slot that 798 /// has not yet recorded a view. 799 static func parsePlayerPresenceUntil(from record: CKRecord) -> Date? { 800 record["presenceUntil"] as? Date 801 } 802 803 /// Reads `readThrough` off an inbound Player record — the per-account read 804 /// watermark: the latest other-author move time this user has actually 805 /// seen. Unlike `presenceUntil` it is never leased into the future, so the 806 /// session-end push uses it to decide what a recipient still hasn't seen. 807 /// Returns `nil` for records that predate the field or a slot that has not 808 /// recorded a read yet. 809 static func parsePlayerReadThrough(from record: CKRecord) -> Date? { 810 record["readThrough"] as? Date 811 } 812 813 /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. These 814 /// fields carry the peer's cursor track start, not their exact local 815 /// reticle. Returns `nil` if any field is missing — the peer either hasn't 816 /// published a track yet or has cleared theirs (e.g. left the puzzle view). 817 static func parsePlayerSelection(from record: CKRecord) -> PlayerSelection? { 818 guard let row = record["selRow"] as? Int64, 819 let col = record["selCol"] as? Int64, 820 let dirRaw = record["selDir"] as? Int64, 821 let direction = PlayerSelection.Direction(rawValue: Int(dirRaw)) 822 else { return nil } 823 return PlayerSelection(row: Int(row), col: Int(col), direction: direction) 824 } 825 826 /// Reads `viewedAt` off an inbound Player record — this account's "last 827 /// viewed" cutoff, written on leave and shared across the author's own 828 /// devices so a sibling converges on the latest view time rather than 829 /// recomputing the catch-up baseline from its own (possibly stale) view. 830 /// Returns `nil` when the account has not yet left a game with peers. 831 static func parsePlayerViewedAt(from record: CKRecord) -> Date? { 832 record["viewedAt"] as? Date 833 } 834 835 /// Reads `timeLog` off an inbound Player record — the encoded `TimeLog` 836 /// of device-keyed solve-time intervals. Returns `nil` for records that 837 /// predate the field (or before the schema deploy), which the clock treats 838 /// as a zero contribution. 839 static func parsePlayerTimeLog(from record: CKRecord) -> Data? { 840 record["timeLog"] as? Data 841 } 842 843 /// Reads `pushAddress` off an inbound Player record — the per-(account, 844 /// game) capability token a co-participant uses to address a push to this 845 /// player for this game. Possession is gated by the share ACL (only 846 /// participants can read the record), so the token, not an identity, is 847 /// the authorisation. Returns `nil` for older records or a slot that has 848 /// not yet minted one. 849 static func parsePlayerPushAddress(from record: CKRecord) -> String? { 850 record["pushAddress"] as? String 851 } 852 853 /// Parses an incoming `Player` record name back into its `(gameID, 854 /// authorID)` components. Returns `nil` if the name doesn't match the 855 /// `player-<UUID>-<authorID>` shape. 856 static func parsePlayerRecordName(_ name: String) -> (UUID, String)? { 857 guard name.hasPrefix("player-") else { return nil } 858 let rest = name.dropFirst("player-".count) 859 let uuidLength = 36 860 guard rest.count > uuidLength, 861 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 862 else { return nil } 863 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 864 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 865 let authorPart = String(rest.suffix(from: rest.index(rest.startIndex, offsetBy: uuidLength + 1))) 866 guard !authorPart.isEmpty else { return nil } 867 return (gameID, authorPart) 868 } 869 870 /// Parses an incoming `Moves` CKRecord into a `MovesValue`. Returns `nil` 871 /// if the record name doesn't match the `moves-<gameUUID>-<authorID>-<deviceID>` 872 /// shape or the cells payload fails to decode. 873 static func parseMovesRecord(_ record: CKRecord) -> MovesValue? { 874 guard record.recordType == "Moves" else { return nil } 875 guard let (gameID, authorID, deviceID) = parseMovesRecordName( 876 record.recordID.recordName 877 ) else { return nil } 878 guard let data = record["cells"] as? Data, 879 let cells = try? MovesCodec.decode(data) 880 else { return nil } 881 let updatedAt = record["updatedAt"] as? Date 882 ?? record.modificationDate 883 ?? Date() 884 return MovesValue( 885 gameID: gameID, 886 authorID: authorID, 887 deviceID: deviceID, 888 cells: cells, 889 updatedAt: updatedAt 890 ) 891 } 892 893 /// Parses `moves-<gameUUID>-<authorID>-<deviceID>` into its three parts. 894 /// `deviceID` is the suffix after the final `-`; `authorID` may itself 895 /// contain dashes (e.g. CloudKit user record names with no dashes today, 896 /// but we don't want to assume). 897 static func parseMovesRecordName(_ name: String) -> (UUID, String, String)? { 898 let prefix = "moves-" 899 guard name.hasPrefix(prefix) else { return nil } 900 let rest = name.dropFirst(prefix.count) 901 let uuidLength = 36 902 guard rest.count > uuidLength, 903 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 904 else { return nil } 905 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 906 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 907 let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) 908 let tail = rest[afterUUID...] 909 guard let lastDash = tail.lastIndex(of: "-") else { return nil } 910 let authorID = String(tail[tail.startIndex..<lastDash]) 911 let deviceID = String(tail[tail.index(after: lastDash)...]) 912 guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } 913 return (gameID, authorID, deviceID) 914 } 915 916 /// Parses `journal-<gameUUID>-<authorID>-<deviceID>` into its three parts. 917 /// Same decomposition as `parseMovesRecordName` (deviceID is the suffix 918 /// after the final `-`; authorID may itself contain dashes). 919 static func parseJournalRecordName(_ name: String) -> (UUID, String, String)? { 920 let prefix = "journal-" 921 guard name.hasPrefix(prefix) else { return nil } 922 let rest = name.dropFirst(prefix.count) 923 let uuidLength = 36 924 guard rest.count > uuidLength, 925 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 926 else { return nil } 927 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 928 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 929 let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) 930 let tail = rest[afterUUID...] 931 guard let lastDash = tail.lastIndex(of: "-") else { return nil } 932 let authorID = String(tail[tail.startIndex..<lastDash]) 933 let deviceID = String(tail[tail.index(after: lastDash)...]) 934 guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } 935 return (gameID, authorID, deviceID) 936 } 937 938 /// Parses `ping-<gameUUID>-<authorID>-<deviceID>-<eventTimestampMs>` into 939 /// its identity components. Splits from the right: the trailing 940 /// `eventTimestampMs` is dash-free digits and `deviceID` is a dash-free hex 941 /// id, so both peel off cleanly; `authorID` is the remainder and may itself 942 /// contain dashes (same tolerance as `parseMovesRecordName`). Returns nil if 943 /// the name isn't a ping name or the UUID doesn't parse. 944 static func parsePingRecordName(_ name: String) -> (UUID, String, String)? { 945 let prefix = "ping-" 946 guard name.hasPrefix(prefix) else { return nil } 947 let rest = name.dropFirst(prefix.count) 948 let uuidLength = 36 949 guard rest.count > uuidLength, 950 rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-" 951 else { return nil } 952 let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)]) 953 guard let gameID = UUID(uuidString: uuidPart) else { return nil } 954 let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1) 955 // `<authorID>-<deviceID>-<eventTimestampMs>` 956 let tail = rest[afterUUID...] 957 guard let tsDash = tail.lastIndex(of: "-") else { return nil } 958 let authorAndDevice = tail[tail.startIndex..<tsDash] 959 guard let deviceDash = authorAndDevice.lastIndex(of: "-") else { return nil } 960 let authorID = String(authorAndDevice[authorAndDevice.startIndex..<deviceDash]) 961 let deviceID = String(authorAndDevice[authorAndDevice.index(after: deviceDash)...]) 962 guard !authorID.isEmpty, !deviceID.isEmpty else { return nil } 963 return (gameID, authorID, deviceID) 964 } 965 966 // MARK: - Bounded asset reads 967 968 /// A `CKAsset` staging file rejected before it was read into memory. 969 enum AssetReadError: Error, CustomStringConvertible { 970 case oversized(bytes: Int, limit: Int) 971 case unknownSize 972 973 var description: String { 974 switch self { 975 case .oversized(let bytes, let limit): 976 return "asset exceeds \(limit) bytes (\(bytes))" 977 case .unknownSize: 978 return "asset file size unavailable" 979 } 980 } 981 } 982 983 /// Reads an untrusted `CKAsset` staging file only after its on-disk size 984 /// passes `limit`. A co-player controls the asset's content, and an asset 985 /// is an external file that can dwarf the ~1 MB record limit — so never 986 /// `Data(contentsOf:)` one unchecked. Fails closed when the size can't be 987 /// determined. 988 static func boundedAssetData(at url: URL, limit: Int) throws -> Data { 989 guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { 990 throw AssetReadError.unknownSize 991 } 992 guard size <= limit else { 993 throw AssetReadError.oversized(bytes: size, limit: limit) 994 } 995 return try Data(contentsOf: url) 996 } 997 998 // MARK: - Applying incoming CKRecords to Core Data 999 1000 /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if 1001 /// none exists yet. Moves and Player records can arrive in a different 1002 /// fetch batch than the Game record that created the zone — on 1003 /// a fresh device CKSyncEngine paginates the initial pull and there is no 1004 /// guarantee that Game comes first. Without this stub the parent lookup 1005 /// fails, the inbound record is dropped, but CKSyncEngine still advances 1006 /// its change token, so the gap is invisible until the next state reset. 1007 /// The stub uses empty `title` / `puzzleSource` so `GameSummary.init?` 1008 /// filters it out of the library until `applyGameRecord` arrives with 1009 /// the real metadata and updates the same row (matched by `ckRecordName`). 1010 static func ensureGameEntity( 1011 forGameID gameID: UUID, 1012 zoneID: CKRecordZone.ID, 1013 databaseScope: DatabaseScope = .private, 1014 in ctx: NSManagedObjectContext 1015 ) -> GameEntity { 1016 let name = recordName(forGameID: gameID) 1017 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1018 req.predicate = gameIdentityPredicate( 1019 recordName: name, 1020 zoneID: zoneID, 1021 databaseScope: databaseScope 1022 ) 1023 req.fetchLimit = 1 1024 if let existing = try? ctx.fetch(req).first { return existing } 1025 let entity = GameEntity(context: ctx) 1026 entity.id = gameID 1027 entity.ckRecordName = name 1028 entity.ckZoneName = zoneID.zoneName 1029 // Scope, not the owner-name spelling, decides ownership: a private-DB 1030 // zone always belongs to the current user even when its fetched zone 1031 // ID carries the concrete user-record ID instead of the placeholder. 1032 entity.ckZoneOwnerName = databaseScope == .private ? nil : zoneID.ownerName 1033 entity.databaseScope = databaseScope.rawValue 1034 entity.syncVersion = GameSyncVersion.legacy 1035 entity.title = "" 1036 entity.puzzleSource = "" 1037 entity.createdAt = Date() 1038 entity.updatedAt = Date() 1039 return entity 1040 } 1041 1042 static func applyGameRecord( 1043 _ record: CKRecord, 1044 to context: NSManagedObjectContext, 1045 databaseScope: DatabaseScope = .private, 1046 onEngagementChange: ((UUID) -> Void)? = nil, 1047 onCompletedTransition: ((UUID) -> Void)? = nil, 1048 onContentKeyChange: ((UUID) -> Void)? = nil, 1049 onStaleCredentials: ((String) -> Void)? = nil, 1050 onDiagnostic: ((String) -> Void)? = nil 1051 ) -> GameEntity { 1052 let recordName = record.recordID.recordName 1053 let entity = fetchOrCreate( 1054 entityName: "GameEntity", 1055 recordName: recordName, 1056 zoneID: record.recordID.zoneID, 1057 databaseScope: databaseScope, 1058 in: context 1059 ) as! GameEntity 1060 1061 // Recover the UUID from the record name ("game-<UUID>") so the 1062 // library query, which filters on `entity.id`, doesn't silently drop 1063 // newly-synced games. 1064 if entity.id == nil { 1065 let uuidString = String(recordName.dropFirst("game-".count)) 1066 entity.id = UUID(uuidString: uuidString) 1067 } 1068 1069 // Drop fetched snapshots older than what we already have; adopting 1070 // them downgrades the local etag and OpLock-fails the next save 1071 // (same rationale as `applyMovesRecord` / `applyPlayerRecord`). 1072 if entity.ckSystemFields != nil, 1073 !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) { 1074 return entity 1075 } 1076 1077 // Always adopt the fresher etag and zone identity so the next outbound 1078 // push uses a current change tag and routes to the right zone. 1079 entity.ckRecordName = recordName 1080 entity.ckSystemFields = encodeSystemFields(of: record) 1081 entity.ckZoneName = record.recordID.zoneID.zoneName 1082 // Scope, not the owner-name spelling, decides ownership — CloudKit 1083 // round-trips a private zone's owner as either the placeholder or the 1084 // concrete user-record ID, and `gameIdentityPredicate` relies on 1085 // private rows always storing nil. 1086 entity.ckZoneOwnerName = 1087 databaseScope == .private ? nil : record.recordID.zoneID.ownerName 1088 entity.databaseScope = databaseScope.rawValue 1089 1090 // The owner chooses and may advance the game's protocol. Participants 1091 // never write this field, so they can adopt it even while an unrelated 1092 // metadata save is pending. An owner with a pending local save keeps 1093 // its chosen value until that save round-trips. 1094 if databaseScope == .shared || !entity.hasPendingSave { 1095 entity.syncVersion = GameSyncVersion.normalized( 1096 (record["syncVersion"] as? Int64) ?? GameSyncVersion.legacy 1097 ) 1098 } 1099 1100 // Local mutable fields take precedence while a push is in flight. 1101 // The flag is set atomically with the local write (in `markCompleted`, 1102 // `resignGame`, `persistShareName`) and cleared once `SyncEngine` 1103 // confirms the push landed. Writing server values here would clobber 1104 // the pending change and the next outbound push would then serialise 1105 // the clobbered value, permanently losing it server-side. 1106 guard !entity.hasPendingSave else { return entity } 1107 1108 // Seed createdAt/updatedAt from the server record only on first sight, 1109 // so a newly-arrived game has something for the library to order by. 1110 // After that, the library timestamp tracks *gameplay* (Moves) alone. 1111 // The Game record's modificationDate advances on non-gameplay writes 1112 // too — engagement/push credentials, share metadata, the notification 1113 // field — so adopting it on every fetch made a game look freshly 1114 // "updated" when nothing was played (e.g. a peer, or this device, 1115 // merely moved the cursor or re-minted a credential). Gameplay flows 1116 // through the Moves path, which sets updatedAt independently; the 1117 // winning move is itself a move, so completion still advances it. 1118 if entity.createdAt == nil { 1119 entity.createdAt = record.creationDate ?? Date() 1120 } 1121 if entity.updatedAt == nil { 1122 entity.updatedAt = record.modificationDate ?? Date() 1123 } 1124 1125 entity.title = record["title"] as? String ?? entity.title 1126 // Capture the prior completion state before overwriting it: a 1127 // not-completed → completed transition learned purely via sync (this 1128 // device wasn't present when the puzzle was finished) does NOT run the 1129 // local completion path, so it never uploads this device's journal. 1130 // Replay's strict completeness would then wait on it forever. Signal 1131 // the transition so the caller can enqueue the upload. 1132 let wasCompleted = entity.completedAt != nil 1133 let incomingCompletedAt = record["completedAt"] as? Date 1134 if let incomingCompletedAt { 1135 entity.completedAt = incomingCompletedAt 1136 entity.completedBy = record["completedBy"] as? String 1137 } else if entity.completedAt == nil { 1138 entity.completedAt = nil 1139 entity.completedBy = nil 1140 } 1141 if !wasCompleted, entity.completedAt != nil, let id = entity.id { 1142 onCompletedTransition?(id) 1143 } 1144 // Owner-side share marker — set on the device that created the share 1145 // and round-tripped via the Game record so other owner-devices learn 1146 // the game is shared. On participant devices `databaseScope == 1` 1147 // already implies shared, but keeping the field in sync is harmless. 1148 if let shareRecordName = record["shareRecordName"] as? String { 1149 entity.ckShareRecordName = shareRecordName 1150 } 1151 1152 // Adopt the engagement creds (skipped above while a local mint is 1153 // still pushing, via the hasPendingSave guard). A change here is the 1154 // signal a peer minted/rotated the room, so the receiver reconciles 1155 // its live connection toward the new creds. 1156 let incomingEngagement = (record["roomCredential"] as? Data) 1157 .flatMap { String(data: $0, encoding: .utf8) } 1158 if entity.engagement != incomingEngagement { 1159 entity.engagement = incomingEngagement 1160 if let id = entity.id { onEngagementChange?(id) } 1161 } 1162 1163 // Adopt the shared notification credentials. The credential is read 1164 // lazily at registration/publish time, so converging the field is 1165 // enough — but a change may carry a new embedded content key, so fire 1166 // `onContentKeyChange` to re-mirror the App Group key directory the NSE 1167 // reads. This is what lets a freshly-joined participant decrypt the 1168 // first push it receives, rather than waiting for the next launch heal. 1169 // 1170 // Adoption is generation-gated: rotation replaces the credential at a 1171 // higher `gen`, and the Game record is pushed whole, so a stale device 1172 // re-pushing an unrelated change (its inbound applies were blocked by 1173 // its own `hasPendingSave`) can land the *old* credential back on the 1174 // server. Adopting that here would silently re-admit a departed 1175 // participant. Instead keep the newer local credential, flag our copy 1176 // as pending, and hand the record name to `onStaleCredentials` so the 1177 // caller re-pushes it — healing the server to the rotated value. 1178 let incomingNotification = (record["pushCredential"] as? Data) 1179 .flatMap { String(data: $0, encoding: .utf8) } 1180 if entity.notification != incomingNotification { 1181 let localGen = GamePushCredentials.decode(entity.notification)?.generation 1182 let incomingGen = GamePushCredentials.decode(incomingNotification)?.generation 1183 // A local credential is superseded only by an inbound one of equal 1184 // or higher generation (equal = concurrent rotations; the server 1185 // copy wins the tie). An inbound *absence* never clears a local 1186 // credential — creds are minted once and only ever replaced, so a 1187 // missing field is always a stale record. 1188 let incomingSupersedes: Bool 1189 if localGen == nil { 1190 incomingSupersedes = true 1191 } else if let incomingGen, let localGen { 1192 incomingSupersedes = incomingGen >= localGen 1193 } else { 1194 incomingSupersedes = false 1195 } 1196 if incomingSupersedes { 1197 entity.notification = incomingNotification 1198 if let id = entity.id { onContentKeyChange?(id) } 1199 } else { 1200 onDiagnostic?( 1201 "stale pushCredential for \(recordName) " + 1202 "(inbound gen \(incomingGen.map(String.init) ?? "none") < " + 1203 "local gen \(localGen.map(String.init) ?? "none")) — keeping local, re-pushing" 1204 ) 1205 entity.hasPendingSave = true 1206 onStaleCredentials?(recordName) 1207 } 1208 } 1209 1210 if let asset = record["puzzleSource"] as? CKAsset, 1211 let fileURL = asset.fileURL { 1212 do { 1213 // A co-player writes this asset into the shared zone, so treat 1214 // its size as untrusted: reject an oversized blob before reading 1215 // it into memory. The parser enforces the same bound, but the 1216 // asset is an external file that could dwarf the ~1 MB record 1217 // limit, so gate on the on-disk size first. 1218 if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize, 1219 size > XD.maxSourceBytes { 1220 onDiagnostic?( 1221 "puzzleSource asset for \(recordName) " + 1222 "exceeds \(XD.maxSourceBytes) bytes (\(size)) — skipping" 1223 ) 1224 return entity 1225 } 1226 let source = try String(contentsOf: fileURL, encoding: .utf8) 1227 entity.puzzleSource = source 1228 if let xd = try? XD.parse(source) { 1229 let puzzle = Puzzle(xd: xd) 1230 entity.puzzleParserVersion = Int64(XD.currentParserVersion) 1231 // The title is always derived from the puzzle content (there 1232 // is no custom game title), so trust the asset over the 1233 // record's `title` field — which was already applied above. 1234 // This re-derives the real title even when `record["title"]` 1235 // carried a stale value, e.g. a participant's transient 1236 // "Joining…" placeholder that a prior build wrote to the 1237 // shared record, so the title self-heals on the next sync 1238 // that carries the asset. 1239 entity.title = puzzle.title 1240 entity.populateCachedSummaryFields(from: puzzle) 1241 } 1242 } catch { 1243 // CKSyncEngine has already committed this batch by the time 1244 // the delegate returns, so re-throwing wouldn't redeliver. 1245 // Surface the dropped puzzle source instead of silently 1246 // leaving the entity without playable content. 1247 let nsError = error as NSError 1248 onDiagnostic?( 1249 "puzzleSource asset read failed for \(recordName) " + 1250 "— domain=\(nsError.domain) code=\(nsError.code) " + 1251 "\(nsError.localizedDescription)" 1252 ) 1253 } 1254 } 1255 1256 return entity 1257 } 1258 1259 /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight 1260 /// off the record so any forward-compat fields the encoder added are 1261 /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the 1262 /// record is fresher. Returns `true` when cells/updatedAt were adopted, 1263 /// `false` when the local-device-row guard short-circuited the body so 1264 /// callers can skip the downstream grid refresh. 1265 /// 1266 /// `onNewAuthor` fires with the authorID when this record creates the first 1267 /// `MovesEntity` row by a *remote* contributor — i.e. a participant the 1268 /// roster can only discover from their moves (no `Player` record yet, see 1269 /// `PlayerRoster.refresh`). Callers use it to trigger a one-off roster 1270 /// refresh on a new collaborator's first move without refreshing on every 1271 /// subsequent keystroke or sibling-device row. 1272 @discardableResult 1273 static func applyMovesRecord( 1274 _ record: CKRecord, 1275 value: MovesValue, 1276 to ctx: NSManagedObjectContext, 1277 databaseScope: DatabaseScope = .private, 1278 localAuthorID: String? = nil, 1279 onNewAuthor: ((String) -> Void)? = nil 1280 ) -> Bool { 1281 let ckName = record.recordID.recordName 1282 let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 1283 req.predicate = gameIdentityPredicate( 1284 recordName: ckName, 1285 zoneID: record.recordID.zoneID, 1286 databaseScope: databaseScope, 1287 entityPrefix: "game" 1288 ) 1289 req.fetchLimit = 1 1290 1291 let entity: MovesEntity 1292 let foundExisting: Bool 1293 let authorAlreadyKnown: Bool 1294 if let existing = try? ctx.fetch(req).first { 1295 entity = existing 1296 foundExisting = true 1297 authorAlreadyKnown = true 1298 } else { 1299 let authorReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") 1300 authorReq.predicate = NSPredicate( 1301 format: "game.id == %@ AND authorID == %@", 1302 value.gameID as CVarArg, 1303 value.authorID 1304 ) 1305 authorReq.fetchLimit = 1 1306 authorAlreadyKnown = ((try? ctx.fetch(authorReq).first) != nil) 1307 1308 let game = ensureGameEntity( 1309 forGameID: value.gameID, 1310 zoneID: record.recordID.zoneID, 1311 databaseScope: databaseScope, 1312 in: ctx 1313 ) 1314 entity = MovesEntity(context: ctx) 1315 entity.game = game 1316 foundExisting = false 1317 } 1318 1319 // Drop fetched snapshots that are older than what we already have. 1320 // The writeback after a successful push advances `ckSystemFields` to 1321 // the latest server etag; a query that started before that push 1322 // landed can return the prior server state, and adopting it here 1323 // would downgrade the etag and OpLock-fail the next save. 1324 if foundExisting, 1325 !incomingIsAtLeastAsFresh(record, existingFields: entity.ckSystemFields) { 1326 return false 1327 } 1328 1329 // Adopt system fields so future saves target the server's current 1330 // change tag. If this is our own per-device row and it already 1331 // exists locally, the local value state is authoritative; tokenless 1332 // push-driven direct fetches can re-deliver an older server copy while 1333 // newer edits are still queued for upload. 1334 entity.ckRecordName = ckName 1335 entity.ckSystemFields = encodeSystemFields(of: record) 1336 entity.authorID = value.authorID 1337 entity.deviceID = value.deviceID 1338 let isLocalDeviceRow = value.authorID == localAuthorID 1339 && value.deviceID == localDeviceID 1340 guard !foundExisting || !isLocalDeviceRow else { return false } 1341 let previousUpdatedAt = entity.updatedAt ?? .distantPast 1342 let previousCells = (entity.cells.flatMap { try? MovesCodec.decode($0) }) ?? [:] 1343 let mergedCells = mergeIncomingMovesCells( 1344 existing: previousCells, 1345 incoming: value.cells 1346 ) 1347 let mergedUpdatedAt = max( 1348 previousUpdatedAt, 1349 value.updatedAt, 1350 mergedCells.values.map(\.updatedAt).max() ?? .distantPast 1351 ) 1352 1353 entity.updatedAt = mergedUpdatedAt 1354 entity.cells = (try? MovesCodec.encode(mergedCells)) ?? ((record["cells"] as? Data) ?? Data()) 1355 1356 if let game = entity.game, 1357 game.updatedAt.map({ $0 < mergedUpdatedAt }) ?? true { 1358 game.updatedAt = mergedUpdatedAt 1359 } 1360 // A newly-seen remote author is the roster's only cue to 1361 // a contributor who hasn't published a `Player` record yet. Signal it 1362 // once here; repeat moves and sibling-device rows by a known author 1363 // don't. 1364 if !foundExisting, 1365 !authorAlreadyKnown, 1366 value.authorID != localAuthorID, 1367 value.authorID != CKCurrentUserDefaultName, 1368 !value.authorID.isEmpty { 1369 onNewAuthor?(value.authorID) 1370 } 1371 return true 1372 } 1373 1374 private static func mergeIncomingMovesCells( 1375 existing: [GridPosition: TimestampedCell], 1376 incoming: [GridPosition: TimestampedCell] 1377 ) -> [GridPosition: TimestampedCell] { 1378 var cells = existing 1379 for (position, incomingCell) in incoming { 1380 if let existingCell = cells[position], 1381 existingCell.compareRevision(to: incomingCell) != .orderedAscending { 1382 continue 1383 } 1384 cells[position] = incomingCell 1385 } 1386 return cells 1387 } 1388 1389 /// Projects an inbound `Decision` record onto local Core Data. For 1390 /// `kind == "block"` this upserts a `FriendEntity` tombstone keyed by the 1391 /// blocked author so the block becomes authoritative across the user's own 1392 /// devices: `applyInvitePings` (authorID-keyed) and the friendship 1393 /// bootstrap's `friendExists` (pairKey-keyed) both then suppress the 1394 /// blocked collaborator everywhere. A device that never befriended the 1395 /// author still gets a minimal blocked row. Returns `true` when a row was 1396 /// written. `localAuthorID` lets the pairKey be derived for the bootstrap 1397 /// short-circuit; it's deterministic from the unordered author pair. 1398 @discardableResult 1399 static func applyDecisionRecord( 1400 _ record: CKRecord, 1401 to ctx: NSManagedObjectContext, 1402 localAuthorID: String?, 1403 databaseScope: DatabaseScope = .private 1404 ) -> Bool { 1405 guard record.recordType == "Decision" else { return false } 1406 // Identity comes from the record name (always present, immutable); 1407 // `kind` is also mirrored as a field, name-parse as the fallback. 1408 let parsed = parseDecisionRecordName(record.recordID.recordName) 1409 guard let kind = (record["kind"] as? String) ?? parsed?.kind, 1410 let key = parsed?.key, 1411 !kind.isEmpty, !key.isEmpty 1412 else { return false } 1413 1414 switch kind { 1415 case blockDecisionKind: 1416 // A block is the user's own choice, authoritative across their 1417 // devices — honored only from the account zone in the private 1418 // database. Friend zones are writable by the other participant, who 1419 // must not be able to block (or unblock) a third party on this 1420 // user's behalf. Same gate as `nickname`/`left`. 1421 guard isAccountZonePrivateDecision(record, databaseScope: databaseScope) 1422 else { return false } 1423 // Versioned so a stale copy can't resurrect a cleared block (or 1424 // re-clear a fresh one). Payload `"0"` means unblocked; anything 1425 // else (including a legacy payload-less record) means blocked. 1426 let version = decisionVersion(record) 1427 let blocked = (record["payload"] as? String) != "0" 1428 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1429 req.predicate = NSPredicate(format: "authorID == %@", key) 1430 req.fetchLimit = 1 1431 let friend = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) 1432 guard version >= friend.blockVersion else { return false } 1433 friend.authorID = key 1434 friend.isBlocked = blocked 1435 friend.blockVersion = version 1436 if friend.pairKey == nil, 1437 let localAuthorID, !localAuthorID.isEmpty { 1438 friend.pairKey = FriendZone.pairKey(localAuthorID, key) 1439 } 1440 if friend.createdAt == nil { friend.createdAt = Date() } 1441 return true 1442 case "left": 1443 // The user left this shared game on another of their devices. 1444 // Hard-delete the local row so it stops hanging around (the 1445 // shared-zone deletion alone would only flag it access-revoked, 1446 // see SyncEngine.handleFetchedDatabaseChanges). Idempotent: a 1447 // re-applied decision after the row is gone is a no-op. Guarded 1448 // to participant rows (databaseScope == 1) so a same-id owned 1449 // copy is never collateral. 1450 // 1451 // The decision itself is a self-authored cross-device signal, so 1452 // honor it only from the account zone in the private database — a 1453 // friend must not be able to name a `(gameID)` and evict its 1454 // participant row out of a zone he can write. Same gate as 1455 // `nickname`/`block`. 1456 guard isAccountZonePrivateDecision(record, databaseScope: databaseScope) 1457 else { return false } 1458 guard let gameID = UUID(uuidString: key) else { return false } 1459 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 1460 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 1461 req.fetchLimit = 1 1462 guard let entity = try? ctx.fetch(req).first, 1463 entity.databaseScope == 1 else { return false } 1464 ctx.delete(entity) 1465 return true 1466 case nameDecisionKind: 1467 // A friend's display name, read out of the pairwise friend zone. 1468 // Our own copy (key == localAuthorID, account zone) carries no 1469 // Core Data projection — the local name lives in 1470 // `PlayerPreferences`; only its version is adopted, by the caller. 1471 guard let localAuthorID, !localAuthorID.isEmpty, 1472 key != localAuthorID, 1473 let name = record["payload"] as? String, 1474 !name.isEmpty 1475 else { return false } 1476 // Provenance: a name Decision is only honored when the zone is *the* 1477 // pairwise mailbox for (us, key) — the zone name embeds a hash of 1478 // the author pair, so the claimed subject is verifiable without 1479 // trusting the record. This also rejects a name Decision for a third 1480 // party misdelivered into an unrelated zone. 1481 let pairKey = FriendZone.pairKey(localAuthorID, key) 1482 let zoneID = record.recordID.zoneID 1483 guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else { 1484 return false 1485 } 1486 // The friend writes their name into *our inbox* (their outbox), so 1487 // a friend's name only ever arrives via the zone we own — the 1488 // private engine. Reject one seen at shared scope (that would be 1489 // the friend's own inbox, not the channel they tell us their name 1490 // through). 1491 guard databaseScope == .private else { return false } 1492 let version = decisionVersion(record) 1493 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1494 req.predicate = NSPredicate(format: "pairKey == %@", pairKey) 1495 req.fetchLimit = 1 1496 if let friend = try? ctx.fetch(req).first { 1497 guard !friend.isBlocked, 1498 version >= friend.displayNameVersion 1499 else { return false } 1500 friend.displayName = name 1501 friend.displayNameVersion = version 1502 return true 1503 } 1504 // No local row: the bootstrap evidence (game zones, `.friend` 1505 // Ping) may be long gone on a restored device, but the name 1506 // Decision arriving from our inbox is itself proof of the 1507 // friendship — resurrect the row. Both mailbox zones are derivable 1508 // from `pairKey` + `authorID`, so no zone fields are stored. 1509 let friend = FriendEntity(context: ctx) 1510 friend.authorID = key 1511 friend.pairKey = pairKey 1512 friend.createdAt = Date() 1513 friend.displayName = name 1514 friend.displayNameVersion = version 1515 return true 1516 case nicknameDecisionKind: 1517 // The user's private nickname for a friend, authoritative across 1518 // their own devices. Honored only from the account zone in our own 1519 // private database: friend zones are writable by the other 1520 // participant, who must not be able to relabel people in this 1521 // user's friends list. Match on zone *name* + private scope, not a 1522 // full `zoneID ==`: a record fetched back from CloudKit does not 1523 // reliably carry the `CKCurrentUserDefaultName` owner placeholder 1524 // `accountZoneID` is built with — its `ownerName` often comes back 1525 // as the concrete user-record ID, so an `==` silently rejects every 1526 // synced nickname. Scoping to the private DB keeps the anti-relabel 1527 // guarantee (a friend can only reach us through the shared DB). 1528 // Same gate as `block`/`left`. 1529 guard isAccountZonePrivateDecision(record, databaseScope: databaseScope) 1530 else { return false } 1531 let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") 1532 req.predicate = NSPredicate(format: "authorID == %@", key) 1533 req.fetchLimit = 1 1534 // No resurrection: unlike a name Decision, an account-zone row 1535 // carries no zone provenance to rebuild a usable friendship from, 1536 // and a zoneless row would surface as an uninvitable friend. 1537 guard let friend = try? ctx.fetch(req).first else { return false } 1538 let version = decisionVersion(record) 1539 guard version >= friend.nicknameVersion else { return false } 1540 let nickname = (record["payload"] as? String)? 1541 .trimmingCharacters(in: .whitespacesAndNewlines) 1542 // Empty/absent payload is a deliberate clear, not a malformed 1543 // record — the rename alert's blank entry reverts to their name. 1544 friend.nickname = (nickname?.isEmpty == false) ? nickname : nil 1545 friend.nicknameVersion = version 1546 return true 1547 case encryptionKeyDecisionKind: 1548 // Friend-zone message encryption material. Each participant writes 1549 // only their own key record, named by authorID; the peer mirrors it 1550 // to the App Group so the Notification Service Extension can 1551 // decrypt invite pushes before the game zone exists locally. 1552 guard let localAuthorID, !localAuthorID.isEmpty, 1553 key != localAuthorID, 1554 let payload = FriendEncryptionKeyPayload.decode(record["payload"] as? String) 1555 else { return false } 1556 let pairKey = FriendZone.pairKey(localAuthorID, key) 1557 let zoneID = record.recordID.zoneID 1558 guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else { 1559 return false 1560 } 1561 // Like the `name` case: the friend writes their key into *our inbox* 1562 // (their outbox), so it only ever arrives via the zone we own — the 1563 // private engine. Reject one seen at shared scope (that would be 1564 // the friend's own inbox, not the channel they publish to us). The 1565 // zone-name gate alone is insufficient: a peer owns a zone that 1566 // *names* itself for this pair and shares it to us at shared scope. 1567 guard databaseScope == .private else { return false } 1568 FriendEncryptionKeyDirectory.upsert(payload, for: key) 1569 return false 1570 default: 1571 // Unknown kind from a newer build — ignore rather than guess. 1572 return false 1573 } 1574 } 1575 1576 // MARK: - System fields encode/decode 1577 1578 static func encodeSystemFields(of record: CKRecord) -> Data? { 1579 let coder = NSKeyedArchiver(requiringSecureCoding: true) 1580 record.encodeSystemFields(with: coder) 1581 coder.finishEncoding() 1582 return coder.encodedData 1583 } 1584 1585 static func decodeRecord(from data: Data) -> CKRecord? { 1586 guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } 1587 coder.requiresSecureCoding = true 1588 let record = CKRecord(coder: coder) 1589 coder.finishDecoding() 1590 return record 1591 } 1592 1593 /// Returns `true` when `incoming` reflects a server state at least as 1594 /// recent as the modification date encoded in `existingFields`. Used by 1595 /// the apply paths to drop fetched snapshots that arrive after our 1596 /// writeback has already adopted a newer change tag — adopting them 1597 /// would downgrade the local etag and the next save would OpLock-fail. 1598 /// Defaults to `true` when either side lacks a modification date so a 1599 /// first-time fetch can land. 1600 static func incomingIsAtLeastAsFresh( 1601 _ incoming: CKRecord, 1602 existingFields: Data? 1603 ) -> Bool { 1604 guard let existingFields, 1605 let existingRecord = decodeRecord(from: existingFields), 1606 let existingDate = existingRecord.modificationDate, 1607 let incomingDate = incoming.modificationDate 1608 else { return true } 1609 return incomingDate >= existingDate 1610 } 1611 1612 // MARK: - Private helpers 1613 1614 /// Restores a `CKRecord` from archived system fields (preserving the 1615 /// server change tag) or creates a fresh one if no archive is available. 1616 private static func restoreOrCreate( 1617 recordType: String, 1618 recordName: String, 1619 zone: CKRecordZone.ID, 1620 systemFields: Data? 1621 ) -> CKRecord { 1622 if let data = systemFields, let restored = decodeRecord(from: data) { 1623 return restored 1624 } 1625 let recordID = CKRecord.ID(recordName: recordName, zoneID: zone) 1626 return CKRecord(recordType: recordType, recordID: recordID) 1627 } 1628 1629 private static func fetchOrCreate( 1630 entityName: String, 1631 recordName: String, 1632 zoneID: CKRecordZone.ID, 1633 databaseScope: DatabaseScope, 1634 in context: NSManagedObjectContext 1635 ) -> NSManagedObject { 1636 let request = NSFetchRequest<NSManagedObject>(entityName: entityName) 1637 request.predicate = gameIdentityPredicate( 1638 recordName: recordName, 1639 zoneID: zoneID, 1640 databaseScope: databaseScope 1641 ) 1642 request.fetchLimit = 1 1643 if let existing = try? context.fetch(request).first { 1644 return existing 1645 } 1646 return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context) 1647 } 1648 1649 }