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