Archive.swift (46053B)
1 import CloudKit 2 import Compression 3 import CoreData 4 import CryptoKit 5 import Foundation 6 7 /// Serialization + materialization for a finished game's compact private 8 /// archive. 9 /// 10 /// When a participant (not the owner) finishes a shared game, that game's data 11 /// lives only in the owner's shared zone; if the owner later deletes it, the 12 /// participant keeps a local copy but has no CloudKit backing, so a new device 13 /// or reinstall loses it. To close that gap every involved account writes a 14 /// self-contained snapshot — final grid + the full multi-author move journal — 15 /// into one common zone in *its own* private database. A finished game is immutable 16 /// (`isCompleted` latches at completion), so the snapshot needs no 17 /// reconciliation: it is written once and only ever read back to rebuild a 18 /// standalone completed game on another device or after the original is revoked. 19 /// 20 /// The snapshot is deliberately *not* a clone of the live multi-record game. 21 /// The live representation keys one Core Data entity to one `CKRecord` identity 22 /// tied to the shared zone (`RecordBuilder`), and the journal-upload path only 23 /// uploads *this device's own* rows — so it cannot reproduce the full 24 /// multi-author journal replay needs. Instead everything is folded into a single 25 /// `Chronicle` record carrying all metadata and game data in one compressed 26 /// payload asset. Once every accepted participant acknowledges that snapshot 27 /// (or the retention deadline expires), the owner can delete the much larger 28 /// live per-game zone without deleting the compact archive. 29 enum Archive { 30 /// The compact v1.1.0 record. It deliberately has a new CloudKit type so 31 /// its schema contains only the single payload Asset. 32 static let recordType = "Chronicle" 33 /// Three-asset records written before v1.1.0. 34 static let legacyRecordType = "Archive" 35 static let zoneName = "completed-archives" 36 static let payloadKey = "payload" 37 38 /// The keys any fetch must ask for to decode a Chronicle. `completedAt` is 39 /// not optional extra metadata: `payload(from:)` cross-checks it against the 40 /// blob's own completion time, and a record fetched without it fails that 41 /// identity guard exactly as a tampered one would. 42 static let payloadDesiredKeys = ["completedAt", payloadKey] 43 44 // MARK: - Inbound asset bounds 45 46 /// Byte cap on the `cells` asset, checked on disk before it is read. The 47 /// largest admissible grid (`XD.maxGridDimension`²) at ~120 bytes of JSON 48 /// per cell is under 2 MiB; real puzzles are a few kilobytes. 49 static let maxCellsAssetBytes = 2_097_152 50 51 /// Byte cap on the merged `journals` asset, checked on disk before it is 52 /// read. Wraps per-device `JournalCodec` blobs (each independently capped 53 /// at `JournalCodec.maxAssetBytes`) in base64; a real finished game's 54 /// merged log is a few hundred kilobytes, so 8 MiB rejects nothing 55 /// genuine. 56 static let maxJournalsAssetBytes = 8_388_608 57 58 /// Bounds both the compressed asset read and the allocation used to 59 /// decompress its versioned payload. The payload contains the three legacy 60 /// assets plus a small amount of metadata; this leaves ample headroom while 61 /// preventing a hostile envelope from requesting an arbitrary allocation. 62 static let maxPayloadAssetBytes = 12_582_912 63 static let maxDecodedPayloadBytes = 16_777_216 64 static let currentPayloadFormatVersion = 3 65 private static let maxParticipantCount = 64 66 67 /// Upper bound on decoded final-grid cells; `XD.maxGridDimension`² is the 68 /// largest cell count any admissible puzzle can produce. 69 static let maxCellCount = XD.maxGridDimension * XD.maxGridDimension 70 71 /// Upper bound on per-device journals in one archive. Every participant 72 /// device that wrote grid state contributes one; real games have a 73 /// handful. 74 static let maxJournalDeviceCount = 64 75 76 /// Namespace for deriving the archive's game id. A fixed random UUID used as 77 /// the v5 namespace so `archiveGameID(for:)` is stable across the 78 /// participant's own devices yet distinct from the original game id. 79 private static let namespace = UUID(uuidString: "1F8B0E2A-3C4D-5E6F-7A8B-9C0D1E2F3A4B")! 80 81 // MARK: - Identity 82 83 /// The deterministic game id of the archived copy. Derived from the original 84 /// game id so every one of the participant's devices computes the same value 85 /// (idempotent re-writes, last-writer-wins on a frozen record) while staying 86 /// distinct from `originalGameID` — the authoring device still holds the live 87 /// original under that id, and Core Data fetches it by `id`. 88 static func archiveGameID(for originalGameID: UUID) -> UUID { 89 var hasher = Insecure.SHA1() 90 hasher.update(data: withUnsafeBytes(of: namespace.uuid) { Data($0) }) 91 hasher.update(data: withUnsafeBytes(of: originalGameID.uuid) { Data($0) }) 92 let digest = Array(hasher.finalize()) 93 var bytes = Array(digest.prefix(16)) 94 // Stamp version (5) and RFC 4122 variant bits, like a real v5 UUID. 95 bytes[6] = (bytes[6] & 0x0F) | 0x50 96 bytes[8] = (bytes[8] & 0x3F) | 0x80 97 let uuid = ( 98 bytes[0], bytes[1], bytes[2], bytes[3], 99 bytes[4], bytes[5], bytes[6], bytes[7], 100 bytes[8], bytes[9], bytes[10], bytes[11], 101 bytes[12], bytes[13], bytes[14], bytes[15] 102 ) 103 return UUID(uuid: uuid) 104 } 105 106 static var zoneID: CKRecordZone.ID { 107 CKRecordZone.ID( 108 zoneName: zoneName, 109 ownerName: CKCurrentUserDefaultName 110 ) 111 } 112 113 /// The per-game zone used by archives written before v1.1.0. Kept solely 114 /// for backward-compatible reads and one-way migration into `zoneID`. 115 static func legacyZoneID(forOriginalGameID gameID: UUID) -> CKRecordZone.ID { 116 CKRecordZone.ID( 117 zoneName: "archive-\(gameID.uuidString)", 118 ownerName: CKCurrentUserDefaultName 119 ) 120 } 121 122 static func recordName(forOriginalGameID gameID: UUID) -> String { 123 "chronicle-\(gameID.uuidString)" 124 } 125 126 static func legacyRecordName(forOriginalGameID gameID: UUID) -> String { 127 "archive-\(gameID.uuidString)" 128 } 129 130 /// The original game id encoded in either generation's record name, or 131 /// `nil` if the name doesn't match. 132 static func originalGameID(fromName name: String) -> UUID? { 133 for prefix in ["chronicle-", "archive-"] where name.hasPrefix(prefix) { 134 return UUID(uuidString: String(name.dropFirst(prefix.count))) 135 } 136 return nil 137 } 138 139 /// Copies the account's mutable unread state from a completed live Game 140 /// onto its local Chronicle projection. The Chronicle payload itself stays 141 /// immutable; these fields are device-local projections of the canonical 142 /// live-game state so the visible Completed tile survives the handoff from 143 /// the hidden Game row. 144 static func mirrorReadState(from live: GameEntity, to chronicle: GameEntity) { 145 if let latest = live.latestOtherMoveAt, 146 (chronicle.latestOtherMoveAt ?? .distantPast) < latest { 147 chronicle.latestOtherMoveAt = latest 148 } 149 if let readThrough = live.readThroughAt, 150 (chronicle.readThroughAt ?? .distantPast) < readThrough { 151 chronicle.readThroughAt = readThrough 152 } 153 } 154 155 static func isArchiveZone(_ zoneName: String) -> Bool { 156 zoneName == self.zoneName || zoneName.hasPrefix("archive-") 157 } 158 159 // MARK: - Final-grid wire format 160 161 /// The final state of one cell, captured so the materialized game renders 162 /// (and its library thumbnail fills) without replaying the journal. 163 struct Cell: Codable, Equatable { 164 let row: Int16 165 let col: Int16 166 let letter: String 167 let markCode: Int16 168 let letterAuthorID: String? 169 } 170 171 /// A frozen roster entry carried inside the compressed Chronicle payload. 172 /// Names remain optional because v1 Chronicles can recover author IDs from 173 /// their journals but cannot reconstruct names after the live zone is gone. 174 struct Participant: Codable, Equatable { 175 let authorID: String 176 let name: String? 177 } 178 179 private static func encodeCells(_ cells: [Cell]) throws -> Data { 180 try JSONEncoder().encode(cells.sorted { 181 ($0.row, $0.col) < ($1.row, $1.col) 182 }) 183 } 184 185 /// A decoded archive asset that exceeds its entry-count bound. The asset 186 /// is rejected whole — a truncated grid or journal set would materialize a 187 /// silently incomplete game. 188 enum LimitError: Error, CustomStringConvertible { 189 case tooManyCells(count: Int) 190 case tooManyDeviceJournals(count: Int) 191 case tooManyParticipants(count: Int) 192 193 var description: String { 194 switch self { 195 case .tooManyCells(let count): 196 return "cells asset exceeds \(maxCellCount) cells (\(count))" 197 case .tooManyDeviceJournals(let count): 198 return "journals asset exceeds \(maxJournalDeviceCount) device journals (\(count))" 199 case .tooManyParticipants(let count): 200 return "archive payload exceeds \(maxParticipantCount) participants (\(count))" 201 } 202 } 203 } 204 205 private static func decodeCells(_ data: Data) throws -> [Cell] { 206 let cells = try JSONDecoder().decode([Cell].self, from: data) 207 guard cells.count <= maxCellCount else { 208 throw LimitError.tooManyCells(count: cells.count) 209 } 210 return cells 211 } 212 213 // MARK: - Per-device journal wire format 214 215 /// One device's log on the wire: its `(authorID, deviceID)` key plus the same 216 /// `JournalCodec` payload the live `Journal` records use, so encoding fidelity 217 /// matches replay exactly. 218 private struct DeviceJournalWire: Codable { 219 let authorID: String 220 let deviceID: String 221 let entries: Data 222 } 223 224 /// The complete archive body. It is encoded as JSON only as an internal 225 /// representation, then wrapped in an authenticated, bounded LZFSE envelope 226 /// and stored as one CKAsset. No field needs to remain queryable in CloudKit: 227 /// record identity carries the original game ID and Crossmate materializes 228 /// the complete payload before displaying it. 229 private struct Blob: Codable { 230 let formatVersion: Int 231 let originalGameID: UUID 232 let archiveGameID: UUID 233 let title: String 234 let puzzleSource: String 235 let completedAt: Date 236 let completedBy: String? 237 let solveSeconds: Int 238 let replayAvailable: Bool 239 /// Added in format 3. A positive value distinguishes a provisional 240 /// Chronicle from the terminal no-replay retention fallback. 241 let replayMissingDeviceCount: Int? 242 let cells: [Cell] 243 let journals: [DeviceJournalWire] 244 /// Added in format 2. Optional so already-written format-1 payloads 245 /// continue to decode and can infer contributors from their journals. 246 let wasShared: Bool? 247 let participants: [Participant]? 248 } 249 250 private static let envelopeMagic = Data("CMARCH01".utf8) 251 private static let envelopeHeaderBytes = 8 + MemoryLayout<UInt64>.size + 32 252 253 enum PayloadError: Error, CustomStringConvertible { 254 case oversizedCompressedPayload(bytes: Int) 255 case oversizedDecodedPayload(bytes: Int) 256 case malformedEnvelope 257 case unsupportedFormat(Int) 258 case decompressionFailed 259 case digestMismatch 260 case identityMismatch 261 case oversizedPuzzleSource(bytes: Int) 262 263 var description: String { 264 switch self { 265 case .oversizedCompressedPayload(let bytes): 266 return "archive payload exceeds \(maxPayloadAssetBytes) compressed bytes (\(bytes))" 267 case .oversizedDecodedPayload(let bytes): 268 return "archive payload exceeds \(maxDecodedPayloadBytes) decoded bytes (\(bytes))" 269 case .malformedEnvelope: 270 return "archive payload envelope is malformed" 271 case .unsupportedFormat(let version): 272 return "archive payload format \(version) is unsupported" 273 case .decompressionFailed: 274 return "archive payload decompression failed" 275 case .digestMismatch: 276 return "archive payload digest does not match" 277 case .identityMismatch: 278 return "archive payload identity does not match its record" 279 case .oversizedPuzzleSource(let bytes): 280 return "archive puzzle source exceeds \(XD.maxSourceBytes) bytes (\(bytes))" 281 } 282 } 283 } 284 285 private static func encodeEnvelope(_ decoded: Data) throws -> Data { 286 guard decoded.count <= maxDecodedPayloadBytes else { 287 throw PayloadError.oversizedDecodedPayload(bytes: decoded.count) 288 } 289 let compressed = try (decoded as NSData).compressed(using: .lzfse) as Data 290 var result = Data() 291 result.reserveCapacity(envelopeHeaderBytes + compressed.count) 292 result.append(envelopeMagic) 293 var length = UInt64(decoded.count).bigEndian 294 withUnsafeBytes(of: &length) { result.append(contentsOf: $0) } 295 result.append(contentsOf: SHA256.hash(data: decoded)) 296 result.append(compressed) 297 guard result.count <= maxPayloadAssetBytes else { 298 throw PayloadError.oversizedCompressedPayload(bytes: result.count) 299 } 300 return result 301 } 302 303 private static func decodeEnvelope(_ envelope: Data) throws -> Data { 304 guard envelope.count >= envelopeHeaderBytes, 305 envelope.prefix(envelopeMagic.count) == envelopeMagic 306 else { throw PayloadError.malformedEnvelope } 307 308 let lengthRange = envelopeMagic.count..<(envelopeMagic.count + MemoryLayout<UInt64>.size) 309 let decodedLength = envelope[lengthRange].reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } 310 guard decodedLength <= UInt64(maxDecodedPayloadBytes), 311 let decodedCount = Int(exactly: decodedLength), 312 decodedCount > 0 313 else { 314 throw PayloadError.oversizedDecodedPayload(bytes: Int(clamping: decodedLength)) 315 } 316 317 let digestStart = lengthRange.upperBound 318 let digestEnd = digestStart + 32 319 let expectedDigest = envelope[digestStart..<digestEnd] 320 let compressed = envelope[digestEnd...] 321 guard !compressed.isEmpty else { throw PayloadError.malformedEnvelope } 322 var decoded = Data(count: decodedCount) 323 let written = decoded.withUnsafeMutableBytes { destination in 324 compressed.withUnsafeBytes { source in 325 compression_decode_buffer( 326 destination.bindMemory(to: UInt8.self).baseAddress!, 327 decodedCount, 328 source.bindMemory(to: UInt8.self).baseAddress!, 329 compressed.count, 330 nil, 331 COMPRESSION_LZFSE 332 ) 333 } 334 } 335 guard written == decodedCount else { throw PayloadError.decompressionFailed } 336 guard Data(SHA256.hash(data: decoded)) == expectedDigest else { 337 throw PayloadError.digestMismatch 338 } 339 return decoded 340 } 341 342 private static func encodeJournals(_ journals: [DeviceJournal]) throws -> Data { 343 try JSONEncoder().encode(journalWire(journals)) 344 } 345 346 private static func journalWire(_ journals: [DeviceJournal]) throws -> [DeviceJournalWire] { 347 try journals 348 .sorted { ($0.key.authorID, $0.key.deviceID) < ($1.key.authorID, $1.key.deviceID) } 349 .map { 350 DeviceJournalWire( 351 authorID: $0.key.authorID, 352 deviceID: $0.key.deviceID, 353 entries: try JournalCodec.encode($0.entries) 354 ) 355 } 356 } 357 358 private static func decodeJournals(_ data: Data) throws -> [DeviceJournal] { 359 let wire = try JSONDecoder().decode([DeviceJournalWire].self, from: data) 360 return try decodeJournals(wire) 361 } 362 363 private static func decodeJournals(_ wire: [DeviceJournalWire]) throws -> [DeviceJournal] { 364 guard wire.count <= maxJournalDeviceCount else { 365 throw LimitError.tooManyDeviceJournals(count: wire.count) 366 } 367 return wire.map { 368 DeviceJournal( 369 key: JournalDeviceKey(authorID: $0.authorID, deviceID: $0.deviceID), 370 // `JournalCodec.decode` enforces its own byte/entry bounds, so 371 // one device's over-limit blob degrades to an empty log rather 372 // than sinking the whole archive. 373 entries: (try? JournalCodec.decode($0.entries)) ?? [] 374 ) 375 } 376 } 377 378 // MARK: - Snapshot taken from local Core Data 379 380 /// Everything needed to build (or rebuild) the archive record, read off the 381 /// local game on a background context at archive time. 382 struct Snapshot { 383 let originalGameID: UUID 384 let title: String 385 let puzzleSource: String 386 let completedAt: Date 387 let completedBy: String? 388 /// The frozen solve-clock value in whole seconds (active solving time, the 389 /// union across all players) at the moment the game finished. Captured 390 /// here because the per-player `Player.timeLog` records it lives on do not 391 /// survive into the archive, so the materialised game would otherwise read 392 /// zero. Whole seconds — the clock is only ever shown at second 393 /// resolution. 394 let solveSeconds: Int 395 let wasShared: Bool 396 let participants: [Participant] 397 let cells: [Cell] 398 /// The full move log, kept *per contributing device* (not flattened) so 399 /// the materialized game replays exactly as the live one does: the replay 400 /// assembler merges one log per device and gates on every expected device 401 /// being present. See `GameArchiver` for how peers' logs are gathered. 402 let journal: [DeviceJournal] 403 404 init( 405 originalGameID: UUID, 406 title: String, 407 puzzleSource: String, 408 completedAt: Date, 409 completedBy: String?, 410 solveSeconds: Int, 411 wasShared: Bool = false, 412 participants: [Participant] = [], 413 cells: [Cell], 414 journal: [DeviceJournal] 415 ) { 416 self.originalGameID = originalGameID 417 self.title = title 418 self.puzzleSource = puzzleSource 419 self.completedAt = completedAt 420 self.completedBy = completedBy 421 self.solveSeconds = solveSeconds 422 self.wasShared = wasShared 423 self.participants = participants 424 self.cells = cells 425 self.journal = journal 426 } 427 } 428 429 /// Reads the local game's finished state, with the journal grouped by 430 /// contributing device. Returns `nil` if the game is not a completed game or 431 /// required fields are missing. The journal here is *local only* — this 432 /// device's own log plus any peer logs already cached for replay; 433 /// `GameArchiver` augments it with a `fetchReplay` of the shared zone while it 434 /// is still reachable. 435 static func snapshot( 436 forGameID gameID: UUID, 437 originalGameID: UUID? = nil, 438 in ctx: NSManagedObjectContext 439 ) -> Snapshot? { 440 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 441 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 442 req.fetchLimit = 1 443 guard let entity = try? ctx.fetch(req).first, 444 let completedAt = entity.completedAt, 445 let source = entity.puzzleSource, !source.isEmpty 446 else { return nil } 447 448 let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] 449 let cells = cellEntities.map { 450 Cell( 451 row: $0.row, 452 col: $0.col, 453 letter: $0.letter ?? "", 454 markCode: $0.markCode, 455 letterAuthorID: $0.letterAuthorID 456 ) 457 } 458 459 let journal = localDeviceJournals(forGameID: gameID, in: ctx) 460 var participantsByAuthor: [String: Participant] = [:] 461 let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 462 playerReq.predicate = NSPredicate(format: "game == %@", entity) 463 for player in (try? ctx.fetch(playerReq)) ?? [] { 464 guard let authorID = player.authorID, !authorID.isEmpty else { continue } 465 let trimmedName = player.name?.trimmingCharacters( 466 in: .whitespacesAndNewlines 467 ) 468 participantsByAuthor[authorID] = Participant( 469 authorID: authorID, 470 name: trimmedName?.isEmpty == false ? trimmedName : nil 471 ) 472 } 473 for encoded in [entity.archiveParticipants, entity.shareParticipants] { 474 for authorID in encoded?.split(separator: ",").map(String.init) ?? [] { 475 guard !authorID.isEmpty else { continue } 476 participantsByAuthor[authorID] = participantsByAuthor[authorID] 477 ?? Participant(authorID: authorID, name: nil) 478 } 479 } 480 for deviceJournal in journal where !deviceJournal.key.authorID.isEmpty { 481 let authorID = deviceJournal.key.authorID 482 participantsByAuthor[authorID] = participantsByAuthor[authorID] 483 ?? Participant(authorID: authorID, name: nil) 484 } 485 486 return Snapshot( 487 originalGameID: originalGameID ?? gameID, 488 title: entity.title ?? "", 489 puzzleSource: source, 490 completedAt: completedAt, 491 completedBy: entity.completedBy, 492 solveSeconds: solveSeconds(forGameID: gameID, asOf: completedAt, in: ctx), 493 wasShared: entity.ckShareRecordName != nil || entity.databaseScope == 1, 494 participants: participantsByAuthor.values.sorted { 495 $0.authorID < $1.authorID 496 }, 497 cells: cells, 498 journal: journal 499 ) 500 } 501 502 /// The union of every player's solve-clock intervals for `gameID`, frozen at 503 /// `asOf` (the completion instant). Mirrors `PlayerRoster.solveTime` but reads 504 /// from the supplied background context for the archive snapshot. 505 private static func solveSeconds( 506 forGameID gameID: UUID, 507 asOf: Date, 508 in ctx: NSManagedObjectContext 509 ) -> Int { 510 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 511 req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 512 let logs = ((try? ctx.fetch(req)) ?? []).map { TimeLog.decode($0.timeLog) } 513 return Int(TimeLog.accumulatedSeconds( 514 forLogs: logs, 515 localDeviceID: RecordSerializer.localDeviceID, 516 asOf: asOf 517 )) 518 } 519 520 /// Groups the local `JournalEntity` rows for a game into per-device logs. 521 /// Own rows (`sourceDeviceID == nil`) form one log keyed to this device; peer 522 /// rows cached for replay carry their own source key. 523 static func localDeviceJournals( 524 forGameID gameID: UUID, 525 in ctx: NSManagedObjectContext 526 ) -> [DeviceJournal] { 527 let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 528 req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) 529 req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] 530 let rows = (try? ctx.fetch(req)) ?? [] 531 532 var byKey: [JournalDeviceKey: [JournalValue]] = [:] 533 for row in rows { 534 let key: JournalDeviceKey 535 if let device = row.sourceDeviceID { 536 key = JournalDeviceKey(authorID: row.sourceAuthorID ?? "", deviceID: device) 537 } else { 538 // This device's own log: keyed to the local device, authored by 539 // whoever typed it (consistently the local user). 540 key = JournalDeviceKey( 541 authorID: row.actingAuthorID ?? "", 542 deviceID: RecordSerializer.localDeviceID 543 ) 544 } 545 byKey[key, default: []].append(MovesJournal.value(from: row)) 546 } 547 return byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } 548 } 549 550 /// Merges peer logs (e.g. from a `fetchReplay`) into a snapshot's journal, 551 /// keeping the local copy of any device already present (it is the 552 /// authoritative, possibly-fresher log for this device). 553 static func merging( 554 _ snapshot: Snapshot, 555 peerJournals: [DeviceJournal] 556 ) -> Snapshot { 557 var byKey: [JournalDeviceKey: [JournalValue]] = [:] 558 for journal in peerJournals { byKey[journal.key] = journal.entries } 559 for journal in snapshot.journal { byKey[journal.key] = journal.entries } 560 return Snapshot( 561 originalGameID: snapshot.originalGameID, 562 title: snapshot.title, 563 puzzleSource: snapshot.puzzleSource, 564 completedAt: snapshot.completedAt, 565 completedBy: snapshot.completedBy, 566 solveSeconds: snapshot.solveSeconds, 567 wasShared: snapshot.wasShared, 568 participants: snapshot.participants, 569 cells: snapshot.cells, 570 journal: byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } 571 ) 572 } 573 574 // MARK: - Record building 575 576 struct RecordPackage { 577 let record: CKRecord 578 let temporaryAssetFileURLs: [URL] 579 } 580 581 static func recordPackage( 582 from snapshot: Snapshot, 583 replayState: ReplayState = .available, 584 formatVersion: Int = currentPayloadFormatVersion 585 ) throws -> RecordPackage { 586 let zone = zoneID 587 let recordID = CKRecord.ID( 588 recordName: recordName(forOriginalGameID: snapshot.originalGameID), 589 zoneID: zone 590 ) 591 let record = CKRecord(recordType: recordType, recordID: recordID) 592 593 let replayAvailable = replayState == .available 594 let blob = Blob( 595 formatVersion: formatVersion, 596 originalGameID: snapshot.originalGameID, 597 archiveGameID: archiveGameID(for: snapshot.originalGameID), 598 title: snapshot.title, 599 puzzleSource: snapshot.puzzleSource, 600 completedAt: snapshot.completedAt, 601 completedBy: snapshot.completedBy, 602 solveSeconds: snapshot.solveSeconds, 603 replayAvailable: replayAvailable, 604 replayMissingDeviceCount: { 605 guard formatVersion >= 3, 606 case .waiting(let missing) = replayState 607 else { return nil } 608 return missing 609 }(), 610 cells: snapshot.cells.sorted { ($0.row, $0.col) < ($1.row, $1.col) }, 611 journals: replayAvailable ? try journalWire(snapshot.journal) : [], 612 wasShared: formatVersion >= 2 ? snapshot.wasShared : nil, 613 participants: formatVersion >= 2 ? snapshot.participants : nil 614 ) 615 let encoded = try JSONEncoder().encode(blob) 616 let payload = try asset(for: encodeEnvelope(encoded), ext: "cmarchive") 617 record["completedAt"] = snapshot.completedAt 618 record[payloadKey] = payload.asset 619 return RecordPackage( 620 record: record, 621 temporaryAssetFileURLs: [payload.url] 622 ) 623 } 624 625 private static func asset(for data: Data, ext: String) throws -> (asset: CKAsset, url: URL) { 626 let url = FileManager.default.temporaryDirectory 627 .appendingPathComponent(UUID().uuidString) 628 .appendingPathExtension(ext) 629 try data.write(to: url, options: .atomic) 630 return (CKAsset(fileURL: url), url) 631 } 632 633 // MARK: - Materialization 634 635 /// The decoded payload of an inbound `Archive` record. 636 struct Payload { 637 let formatVersion: Int 638 let originalGameID: UUID 639 let archiveGameID: UUID 640 let title: String 641 let puzzleSource: String 642 let completedAt: Date 643 let completedBy: String? 644 /// The frozen solve time in whole seconds, or `nil` for archives written 645 /// before the field existed (their materialised game simply shows no time). 646 let solveSeconds: Int? 647 let replayState: ReplayState 648 var replayAvailable: Bool { replayState == .available } 649 let wasShared: Bool 650 let participants: [Participant] 651 let cells: [Cell] 652 let journal: [DeviceJournal] 653 } 654 655 enum ReplayState: Equatable { 656 case available 657 case waiting(missing: Int) 658 case unavailable 659 } 660 661 /// Builds the materialization payload directly from a local snapshot, 662 /// without round-tripping through CloudKit. Used to promote the archive on 663 /// revocation while still offline — the local game data is fully present, so 664 /// the cloud copy need not have landed back. 665 static func payload( 666 from snapshot: Snapshot, 667 replayState: ReplayState = .available 668 ) -> Payload { 669 let replayAvailable = replayState == .available 670 return Payload( 671 formatVersion: currentPayloadFormatVersion, 672 originalGameID: snapshot.originalGameID, 673 archiveGameID: archiveGameID(for: snapshot.originalGameID), 674 title: snapshot.title, 675 puzzleSource: snapshot.puzzleSource, 676 completedAt: snapshot.completedAt, 677 completedBy: snapshot.completedBy, 678 solveSeconds: snapshot.solveSeconds, 679 replayState: replayState, 680 wasShared: snapshot.wasShared, 681 participants: snapshot.participants, 682 cells: snapshot.cells, 683 journal: replayAvailable ? snapshot.journal : [] 684 ) 685 } 686 687 static func payload( 688 from record: CKRecord, 689 onDiagnostic: ((String) -> Void)? = nil 690 ) -> Payload? { 691 switch record.recordType { 692 case recordType: 693 return blobPayload(from: record, onDiagnostic: onDiagnostic) 694 case legacyRecordType: 695 return legacyPayload(from: record, onDiagnostic: onDiagnostic) 696 default: 697 return nil 698 } 699 } 700 701 private static func blobPayload( 702 from record: CKRecord, 703 onDiagnostic: ((String) -> Void)? 704 ) -> Payload? { 705 guard record.recordType == recordType, 706 let recordOriginalID = originalGameID(fromName: record.recordID.recordName), 707 record.recordID.recordName == recordName( 708 forOriginalGameID: recordOriginalID 709 ), 710 let asset = record[payloadKey] as? CKAsset, 711 let url = asset.fileURL 712 else { return nil } 713 do { 714 let envelope = try RecordSerializer.boundedAssetData( 715 at: url, 716 limit: maxPayloadAssetBytes 717 ) 718 let decoded = try decodeEnvelope(envelope) 719 let blob = try JSONDecoder().decode(Blob.self, from: decoded) 720 guard (1...currentPayloadFormatVersion).contains(blob.formatVersion) else { 721 throw PayloadError.unsupportedFormat(blob.formatVersion) 722 } 723 guard let recordCompletedAt = record["completedAt"] as? Date else { 724 throw PayloadError.identityMismatch 725 } 726 let completionMetadataMatches = abs( 727 blob.completedAt.timeIntervalSince(recordCompletedAt) 728 ) < 0.001 729 guard blob.originalGameID == recordOriginalID, 730 blob.archiveGameID == archiveGameID(for: recordOriginalID), 731 completionMetadataMatches 732 else { throw PayloadError.identityMismatch } 733 let sourceBytes = blob.puzzleSource.utf8.count 734 guard sourceBytes <= XD.maxSourceBytes else { 735 throw PayloadError.oversizedPuzzleSource(bytes: sourceBytes) 736 } 737 guard blob.cells.count <= maxCellCount else { 738 throw LimitError.tooManyCells(count: blob.cells.count) 739 } 740 let journals = blob.replayAvailable ? try decodeJournals(blob.journals) : [] 741 let replayState: ReplayState 742 if blob.replayAvailable { 743 replayState = .available 744 } else if let missing = blob.replayMissingDeviceCount, 745 (1...maxJournalDeviceCount).contains(missing) { 746 replayState = .waiting(missing: missing) 747 } else if blob.replayMissingDeviceCount != nil { 748 // An out-of-range count is a corrupt or hostile payload, but the 749 // rest of the Chronicle is still verified and playable. Degrade 750 // to the terminal no-replay state rather than rejecting the 751 // whole archive over a count we only use to word a progress 752 // message. 753 replayState = .unavailable 754 } else { 755 replayState = .unavailable 756 } 757 let participants = try validatedParticipants( 758 blob.participants ?? inferredParticipants( 759 journals: journals, 760 cells: blob.cells, 761 completedBy: blob.completedBy 762 ) 763 ) 764 return Payload( 765 formatVersion: blob.formatVersion, 766 originalGameID: blob.originalGameID, 767 archiveGameID: blob.archiveGameID, 768 title: blob.title, 769 puzzleSource: blob.puzzleSource, 770 completedAt: blob.completedAt, 771 completedBy: blob.completedBy, 772 solveSeconds: blob.solveSeconds, 773 replayState: replayState, 774 wasShared: blob.wasShared 775 ?? (Set(participants.map(\.authorID)).count > 1), 776 participants: participants, 777 cells: blob.cells, 778 journal: journals 779 ) 780 } catch { 781 onDiagnostic?("archive payload rejected for \(record.recordID.recordName): \(error)") 782 return nil 783 } 784 } 785 786 private static func legacyPayload( 787 from record: CKRecord, 788 onDiagnostic: ((String) -> Void)? 789 ) -> Payload? { 790 guard record.recordType == legacyRecordType, 791 let originalString = record["originalGameID"] as? String, 792 let originalGameID = UUID(uuidString: originalString), 793 let archiveString = record["archiveGameID"] as? String, 794 let archiveGameID = UUID(uuidString: archiveString), 795 let completedAt = record["completedAt"] as? Date, 796 record.recordID.recordName == legacyRecordName( 797 forOriginalGameID: originalGameID 798 ), 799 archiveGameID == self.archiveGameID(for: originalGameID) 800 else { return nil } 801 802 // Each asset is size-gated on disk before it is read, then count-gated 803 // on decode. A rejected asset degrades to the same empty default as a 804 // missing one: `materialize` refuses an empty `puzzleSource`, so a 805 // hostile blob can't smuggle an unbounded read in through the archive 806 // path, while a legitimate record with one bad asset still fails soft. 807 func decoded<T>( 808 _ key: String, 809 limit: Int, 810 _ decode: (Data) throws -> T 811 ) -> T? { 812 guard let asset = record[key] as? CKAsset, let url = asset.fileURL 813 else { return nil } 814 do { 815 let data = try RecordSerializer.boundedAssetData(at: url, limit: limit) 816 return try decode(data) 817 } catch { 818 onDiagnostic?( 819 "archive \(key) rejected for " + 820 "\(record.recordID.recordName): \(error)" 821 ) 822 return nil 823 } 824 } 825 826 let puzzleSource = decoded("puzzleSource", limit: XD.maxSourceBytes) { 827 String(data: $0, encoding: .utf8) ?? "" 828 } ?? "" 829 let cells = decoded("cells", limit: maxCellsAssetBytes, decodeCells) ?? [] 830 let journal = decoded("journals", limit: maxJournalsAssetBytes, decodeJournals) ?? [] 831 let participants: [Participant] 832 do { 833 participants = try validatedParticipants( 834 inferredParticipants( 835 journals: journal, 836 cells: cells, 837 completedBy: record["completedBy"] as? String 838 ) 839 ) 840 } catch { 841 onDiagnostic?( 842 "archive participants rejected for " + 843 "\(record.recordID.recordName): \(error)" 844 ) 845 return nil 846 } 847 848 return Payload( 849 formatVersion: 0, 850 originalGameID: originalGameID, 851 archiveGameID: archiveGameID, 852 title: record["title"] as? String ?? "", 853 puzzleSource: puzzleSource, 854 completedAt: completedAt, 855 completedBy: record["completedBy"] as? String, 856 solveSeconds: (record["solveSeconds"] as? Int64).map(Int.init), 857 replayState: .available, 858 wasShared: Set(journal.map(\.key.authorID).filter { !$0.isEmpty }).count > 1, 859 participants: participants, 860 cells: cells, 861 journal: journal 862 ) 863 } 864 865 /// Rebuilds a standalone completed, owned game from an archive payload, under 866 /// the derived `archiveGameID`. Reapplying refreshes the frozen grid and 867 /// replay cache, allowing a complete cloud snapshot to replace an earlier 868 /// local no-replay fallback without creating a duplicate. The row is never 869 /// enqueued for sync, so it pushes no Game/Moves/Player record — the 870 /// `Archive` record in the private zone remains its only cloud identity. 871 /// 872 /// Each contributing device's log is written as `sourceDeviceID`-tagged 873 /// `JournalEntity` rows and `replayCacheComplete` is set, so the existing 874 /// replay path (`GameStore.cachedRemoteJournals`) serves the full merged 875 /// timeline straight from Core Data — no shared zone to fetch from. 876 @discardableResult 877 static func materialize( 878 _ payload: Payload, 879 in ctx: NSManagedObjectContext 880 ) -> GameEntity? { 881 guard !payload.puzzleSource.isEmpty else { return nil } 882 let archiveID = payload.archiveGameID 883 884 let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") 885 request.predicate = NSPredicate(format: "id == %@", archiveID as CVarArg) 886 request.fetchLimit = 1 887 let entity: GameEntity 888 if let existing = try? ctx.fetch(request).first { 889 entity = existing 890 for cell in (existing.cells as? Set<CellEntity>) ?? [] { 891 ctx.delete(cell) 892 } 893 for journal in (existing.journal as? Set<JournalEntity>) ?? [] { 894 ctx.delete(journal) 895 } 896 for player in (existing.players as? Set<PlayerEntity>) ?? [] { 897 ctx.delete(player) 898 } 899 } else { 900 entity = GameEntity(context: ctx) 901 entity.id = archiveID 902 } 903 904 // A sentinel record name: distinct from the `game-` form so no sync 905 // path mistakes the archive for a pushable Game record, while staying 906 // non-nil for code that fetches games by `ckRecordName`. 907 entity.ckRecordName = recordName(forOriginalGameID: payload.originalGameID) 908 entity.ckZoneName = zoneID.zoneName 909 entity.ckZoneOwnerName = nil 910 entity.databaseScope = 0 911 entity.syncVersion = GameSyncVersion.legacy 912 entity.title = payload.title 913 entity.puzzleSource = payload.puzzleSource 914 // Chronicles are ordinary library rows to everything that reads the 915 // derived columns — the browser's "already have this" lookup queries 916 // `cachedPublisher`/`cachedPuzzleDate` directly, and `GameSummary` 917 // would otherwise reparse this XD on every list evaluation. The frozen 918 // `puzzleParserVersion` is deliberately left alone: it still gates the 919 // catalog re-derivation in `preparePuzzleForLoad`. 920 if let xd = try? XD.parse(payload.puzzleSource) { 921 entity.populateCachedSummaryFields(from: Puzzle(xd: xd)) 922 } 923 entity.completedAt = payload.completedAt 924 entity.completedBy = payload.completedBy 925 // The frozen solve time the live clock reached; `PlayerRoster.solveTime` 926 // returns this for a materialised archive, which has no `timeLog` rows. 927 if let solveSeconds = payload.solveSeconds { 928 entity.finalSolveSeconds = NSNumber(value: solveSeconds) 929 } 930 entity.createdAt = payload.completedAt 931 entity.updatedAt = payload.completedAt 932 entity.archivedAt = payload.completedAt 933 entity.archiveGameID = archiveID 934 entity.archiveParticipants = payload.wasShared 935 ? payload.participants.map(\.authorID).sorted().joined(separator: ",") 936 : nil 937 entity.isSupersededByChronicle = false 938 // Every path that turns a Chronicle payload into a local row arrives 939 // here, so this is where the index learns which puzzle the archive 940 // holds — knowledge the eviction that follows would otherwise discard. 941 ChronicleLedgerEntity.upsert( 942 originalGameID: payload.originalGameID, 943 publisher: entity.cachedPublisher, 944 puzzleDate: entity.cachedPuzzleDate, 945 participants: entity.archiveParticipants, 946 in: ctx 947 ) 948 // When the live row is still present, it owns the account's mutable 949 // unread watermark. Mirror that state before the Chronicle becomes the 950 // visible Completed tile (and before retirement may delete the live 951 // row), without putting mutable state into the frozen archive payload. 952 let liveRequest = NSFetchRequest<GameEntity>(entityName: "GameEntity") 953 liveRequest.predicate = NSPredicate( 954 format: "id == %@", 955 payload.originalGameID as CVarArg 956 ) 957 liveRequest.fetchLimit = 1 958 if let live = try? ctx.fetch(liveRequest).first { 959 mirrorReadState(from: live, to: entity) 960 } 961 // Pending Chronicles carry no partial journal, but remain visibly 962 // retryable until reconciliation either captures every device or the 963 // retention deadline turns them into a terminal no-replay fallback. 964 switch payload.replayState { 965 case .available: 966 entity.replayMissingDeviceCount = nil 967 entity.replayUnavailable = false 968 case .waiting(let missing): 969 entity.replayMissingDeviceCount = NSNumber(value: missing) 970 entity.replayUnavailable = false 971 case .unavailable: 972 entity.replayMissingDeviceCount = nil 973 entity.replayUnavailable = true 974 } 975 entity.replayCacheComplete = payload.replayAvailable 976 977 for cell in payload.cells { 978 // The payload is peer-controlled; its Int16 fields can't overflow 979 // (decode throws first) but negatives must not become cache rows. 980 guard cell.row >= 0, cell.col >= 0 else { continue } 981 let row = CellEntity(context: ctx) 982 row.game = entity 983 row.row = cell.row 984 row.col = cell.col 985 row.letter = cell.letter 986 row.markCode = cell.markCode 987 row.letterAuthorID = cell.letterAuthorID 988 } 989 990 for participant in payload.participants { 991 let player = PlayerEntity(context: ctx) 992 player.game = entity 993 player.authorID = participant.authorID 994 player.name = participant.name ?? "" 995 player.ckRecordName = RecordSerializer.recordName( 996 forPlayerInGame: archiveID, 997 authorID: participant.authorID 998 ) 999 player.updatedAt = payload.completedAt 1000 } 1001 1002 // Each device's log is stored as `sourceDeviceID`-tagged rows so the 1003 // replay reader treats every author — including the archiving user's own 1004 // historical moves — as a cached contributor (the archived game has no 1005 // *live* local journal to overlay). 1006 for deviceJournal in payload.replayAvailable ? payload.journal : [] { 1007 for value in deviceJournal.entries { 1008 let row = JournalEntity(context: ctx) 1009 row.game = entity 1010 MovesJournal.assign(value, to: row, gameID: archiveID) 1011 row.sourceAuthorID = deviceJournal.key.authorID 1012 row.sourceDeviceID = deviceJournal.key.deviceID 1013 } 1014 } 1015 1016 return entity 1017 } 1018 1019 private static func inferredParticipants( 1020 journals: [DeviceJournal], 1021 cells: [Cell], 1022 completedBy: String? 1023 ) -> [Participant] { 1024 var authorIDs = Set(journals.map(\.key.authorID)) 1025 authorIDs.formUnion(cells.compactMap(\.letterAuthorID)) 1026 if let completedBy { authorIDs.insert(completedBy) } 1027 authorIDs.remove("") 1028 authorIDs.remove(CKCurrentUserDefaultName) 1029 return authorIDs.sorted().map { Participant(authorID: $0, name: nil) } 1030 } 1031 1032 private static func validatedParticipants( 1033 _ participants: [Participant] 1034 ) throws -> [Participant] { 1035 guard participants.count <= maxParticipantCount else { 1036 throw LimitError.tooManyParticipants(count: participants.count) 1037 } 1038 var byAuthor: [String: Participant] = [:] 1039 for participant in participants where !participant.authorID.isEmpty { 1040 byAuthor[participant.authorID] = participant 1041 } 1042 return byAuthor.values.sorted { $0.authorID < $1.authorID } 1043 } 1044 }