Archive.swift (20193B)
1 import CloudKit 2 import CoreData 3 import CryptoKit 4 import Foundation 5 6 /// Serialization + materialization for the private-zone archive of a finished 7 /// shared game. 8 /// 9 /// When a participant (not the owner) finishes a shared game, that game's data 10 /// lives only in the owner's shared zone; if the owner later deletes it, the 11 /// participant keeps a local copy but has no CloudKit backing, so a new device 12 /// or reinstall loses it. To close that gap each participant writes a 13 /// self-contained snapshot — final grid + the full multi-author move journal — 14 /// into a zone in *their own* private database. A finished game is immutable 15 /// (`isCompleted` latches at completion), so the snapshot needs no 16 /// reconciliation: it is written once and only ever read back to rebuild a 17 /// standalone completed game on another device or after the original is revoked. 18 /// 19 /// The snapshot is deliberately *not* a clone of the live multi-record game. 20 /// The live representation keys one Core Data entity to one `CKRecord` identity 21 /// tied to the shared zone (`RecordBuilder`), and the journal-upload path only 22 /// uploads *this device's own* rows — so it cannot reproduce the full 23 /// multi-author journal replay needs. Instead everything is folded into a single 24 /// `Archive` record carrying `puzzleSource`, the final cells, and one 25 /// merged journal asset. 26 enum Archive { 27 static let recordType = "Archive" 28 29 /// Namespace for deriving the archive's game id. A fixed random UUID used as 30 /// the v5 namespace so `archiveGameID(for:)` is stable across the 31 /// participant's own devices yet distinct from the original game id. 32 private static let namespace = UUID(uuidString: "1F8B0E2A-3C4D-5E6F-7A8B-9C0D1E2F3A4B")! 33 34 // MARK: - Identity 35 36 /// The deterministic game id of the archived copy. Derived from the original 37 /// game id so every one of the participant's devices computes the same value 38 /// (idempotent re-writes, last-writer-wins on a frozen record) while staying 39 /// distinct from `originalGameID` — the authoring device still holds the live 40 /// original under that id, and Core Data fetches it by `id`. 41 static func archiveGameID(for originalGameID: UUID) -> UUID { 42 var hasher = Insecure.SHA1() 43 hasher.update(data: withUnsafeBytes(of: namespace.uuid) { Data($0) }) 44 hasher.update(data: withUnsafeBytes(of: originalGameID.uuid) { Data($0) }) 45 let digest = Array(hasher.finalize()) 46 var bytes = Array(digest.prefix(16)) 47 // Stamp version (5) and RFC 4122 variant bits, like a real v5 UUID. 48 bytes[6] = (bytes[6] & 0x0F) | 0x50 49 bytes[8] = (bytes[8] & 0x3F) | 0x80 50 let uuid = ( 51 bytes[0], bytes[1], bytes[2], bytes[3], 52 bytes[4], bytes[5], bytes[6], bytes[7], 53 bytes[8], bytes[9], bytes[10], bytes[11], 54 bytes[12], bytes[13], bytes[14], bytes[15] 55 ) 56 return UUID(uuid: uuid) 57 } 58 59 static func zoneID(forOriginalGameID gameID: UUID) -> CKRecordZone.ID { 60 CKRecordZone.ID( 61 zoneName: "archive-\(gameID.uuidString)", 62 ownerName: CKCurrentUserDefaultName 63 ) 64 } 65 66 static func recordName(forOriginalGameID gameID: UUID) -> String { 67 "archive-\(gameID.uuidString)" 68 } 69 70 /// The original game id encoded in an `archive-<UUID>` record/zone name, or 71 /// `nil` if the name doesn't match. 72 static func originalGameID(fromName name: String) -> UUID? { 73 guard name.hasPrefix("archive-") else { return nil } 74 return UUID(uuidString: String(name.dropFirst("archive-".count))) 75 } 76 77 static func isArchiveZone(_ zoneName: String) -> Bool { 78 zoneName.hasPrefix("archive-") 79 } 80 81 // MARK: - Final-grid wire format 82 83 /// The final state of one cell, captured so the materialized game renders 84 /// (and its library thumbnail fills) without replaying the journal. 85 struct Cell: Codable, Equatable { 86 let row: Int16 87 let col: Int16 88 let letter: String 89 let markCode: Int16 90 let letterAuthorID: String? 91 } 92 93 private static func encodeCells(_ cells: [Cell]) throws -> Data { 94 try JSONEncoder().encode(cells.sorted { 95 ($0.row, $0.col) < ($1.row, $1.col) 96 }) 97 } 98 99 private static func decodeCells(_ data: Data) throws -> [Cell] { 100 try JSONDecoder().decode([Cell].self, from: data) 101 } 102 103 // MARK: - Per-device journal wire format 104 105 /// One device's log on the wire: its `(authorID, deviceID)` key plus the same 106 /// `JournalCodec` payload the live `Journal` records use, so encoding fidelity 107 /// matches replay exactly. 108 private struct DeviceJournalWire: Codable { 109 let authorID: String 110 let deviceID: String 111 let entries: Data 112 } 113 114 private static func encodeJournals(_ journals: [DeviceJournal]) throws -> Data { 115 let wire = try journals 116 .sorted { ($0.key.authorID, $0.key.deviceID) < ($1.key.authorID, $1.key.deviceID) } 117 .map { 118 DeviceJournalWire( 119 authorID: $0.key.authorID, 120 deviceID: $0.key.deviceID, 121 entries: try JournalCodec.encode($0.entries) 122 ) 123 } 124 return try JSONEncoder().encode(wire) 125 } 126 127 private static func decodeJournals(_ data: Data) throws -> [DeviceJournal] { 128 try JSONDecoder().decode([DeviceJournalWire].self, from: data).map { 129 DeviceJournal( 130 key: JournalDeviceKey(authorID: $0.authorID, deviceID: $0.deviceID), 131 entries: (try? JournalCodec.decode($0.entries)) ?? [] 132 ) 133 } 134 } 135 136 // MARK: - Snapshot taken from local Core Data 137 138 /// Everything needed to build (or rebuild) the archive record, read off the 139 /// local game on a background context at archive time. 140 struct Snapshot { 141 let originalGameID: UUID 142 let title: String 143 let puzzleSource: String 144 let completedAt: Date 145 let completedBy: String? 146 /// The frozen solve-clock value in whole seconds (active solving time, the 147 /// union across all players) at the moment the game finished. Captured 148 /// here because the per-player `Player.timeLog` records it lives on do not 149 /// survive into the archive, so the materialised game would otherwise read 150 /// zero. Whole seconds — the clock is only ever shown at second 151 /// resolution. 152 let solveSeconds: Int 153 let cells: [Cell] 154 /// The full move log, kept *per contributing device* (not flattened) so 155 /// the materialized game replays exactly as the live one does: the replay 156 /// assembler merges one log per device and gates on every expected device 157 /// being present. See `GameArchiver` for how peers' logs are gathered. 158 let journal: [DeviceJournal] 159 } 160 161 /// Reads the local game's finished state, with the journal grouped by 162 /// contributing device. Returns `nil` if the game is not a completed game or 163 /// required fields are missing. The journal here is *local only* — this 164 /// device's own log plus any peer logs already cached for replay; 165 /// `GameArchiver` augments it with a `fetchReplay` of the shared zone while it 166 /// is still reachable. 167 static func snapshot( 168 forGameID gameID: UUID, 169 in ctx: NSManagedObjectContext 170 ) -> Snapshot? { 171 let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") 172 req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) 173 req.fetchLimit = 1 174 guard let entity = try? ctx.fetch(req).first, 175 let completedAt = entity.completedAt, 176 let source = entity.puzzleSource, !source.isEmpty 177 else { return nil } 178 179 let cellEntities = (entity.cells as? Set<CellEntity>) ?? [] 180 let cells = cellEntities.map { 181 Cell( 182 row: $0.row, 183 col: $0.col, 184 letter: $0.letter ?? "", 185 markCode: $0.markCode, 186 letterAuthorID: $0.letterAuthorID 187 ) 188 } 189 190 return Snapshot( 191 originalGameID: gameID, 192 title: entity.title ?? "", 193 puzzleSource: source, 194 completedAt: completedAt, 195 completedBy: entity.completedBy, 196 solveSeconds: solveSeconds(forGameID: gameID, asOf: completedAt, in: ctx), 197 cells: cells, 198 journal: localDeviceJournals(forGameID: gameID, in: ctx) 199 ) 200 } 201 202 /// The union of every player's solve-clock intervals for `gameID`, frozen at 203 /// `asOf` (the completion instant). Mirrors `PlayerRoster.solveTime` but reads 204 /// from the supplied background context for the archive snapshot. 205 private static func solveSeconds( 206 forGameID gameID: UUID, 207 asOf: Date, 208 in ctx: NSManagedObjectContext 209 ) -> Int { 210 let req = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") 211 req.predicate = NSPredicate(format: "game.id == %@", gameID as CVarArg) 212 let logs = ((try? ctx.fetch(req)) ?? []).map { TimeLog.decode($0.timeLog) } 213 return Int(TimeLog.accumulatedSeconds( 214 forLogs: logs, 215 localDeviceID: RecordSerializer.localDeviceID, 216 asOf: asOf 217 )) 218 } 219 220 /// Groups the local `JournalEntity` rows for a game into per-device logs. 221 /// Own rows (`sourceDeviceID == nil`) form one log keyed to this device; peer 222 /// rows cached for replay carry their own source key. 223 static func localDeviceJournals( 224 forGameID gameID: UUID, 225 in ctx: NSManagedObjectContext 226 ) -> [DeviceJournal] { 227 let req = NSFetchRequest<JournalEntity>(entityName: "JournalEntity") 228 req.predicate = NSPredicate(format: "gameID == %@", gameID as CVarArg) 229 req.sortDescriptors = [NSSortDescriptor(key: "seq", ascending: true)] 230 let rows = (try? ctx.fetch(req)) ?? [] 231 232 var byKey: [JournalDeviceKey: [JournalValue]] = [:] 233 for row in rows { 234 let key: JournalDeviceKey 235 if let device = row.sourceDeviceID { 236 key = JournalDeviceKey(authorID: row.sourceAuthorID ?? "", deviceID: device) 237 } else { 238 // This device's own log: keyed to the local device, authored by 239 // whoever typed it (consistently the local user). 240 key = JournalDeviceKey( 241 authorID: row.actingAuthorID ?? "", 242 deviceID: RecordSerializer.localDeviceID 243 ) 244 } 245 byKey[key, default: []].append(MovesJournal.value(from: row)) 246 } 247 return byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } 248 } 249 250 /// Merges peer logs (e.g. from a `fetchReplay`) into a snapshot's journal, 251 /// keeping the local copy of any device already present (it is the 252 /// authoritative, possibly-fresher log for this device). 253 static func merging( 254 _ snapshot: Snapshot, 255 peerJournals: [DeviceJournal] 256 ) -> Snapshot { 257 var byKey: [JournalDeviceKey: [JournalValue]] = [:] 258 for journal in peerJournals { byKey[journal.key] = journal.entries } 259 for journal in snapshot.journal { byKey[journal.key] = journal.entries } 260 return Snapshot( 261 originalGameID: snapshot.originalGameID, 262 title: snapshot.title, 263 puzzleSource: snapshot.puzzleSource, 264 completedAt: snapshot.completedAt, 265 completedBy: snapshot.completedBy, 266 solveSeconds: snapshot.solveSeconds, 267 cells: snapshot.cells, 268 journal: byKey.map { DeviceJournal(key: $0.key, entries: $0.value) } 269 ) 270 } 271 272 // MARK: - Record building 273 274 /// Builds the freshly-minted `Archive` record for a snapshot. Write-once 275 /// and immutable, so — like `Ping`/`Journal` — there is no system-fields 276 /// archive: a re-write of an already-stored archive is a benign conflict the 277 /// save path treats as success. 278 static func record(from snapshot: Snapshot) throws -> CKRecord { 279 let zone = zoneID(forOriginalGameID: snapshot.originalGameID) 280 let recordID = CKRecord.ID( 281 recordName: recordName(forOriginalGameID: snapshot.originalGameID), 282 zoneID: zone 283 ) 284 let record = CKRecord(recordType: recordType, recordID: recordID) 285 record["originalGameID"] = snapshot.originalGameID.uuidString as CKRecordValue 286 record["archiveGameID"] = archiveGameID(for: snapshot.originalGameID).uuidString as CKRecordValue 287 record["title"] = snapshot.title as CKRecordValue 288 record["completedAt"] = snapshot.completedAt as CKRecordValue 289 if let completedBy = snapshot.completedBy { 290 record["completedBy"] = completedBy as CKRecordValue 291 } 292 record["solveSeconds"] = Int64(snapshot.solveSeconds) as CKRecordValue 293 294 record["puzzleSource"] = try asset(for: Data(snapshot.puzzleSource.utf8), ext: "xd") 295 record["cells"] = try asset(for: try encodeCells(snapshot.cells), ext: "json") 296 record["journals"] = try asset(for: try encodeJournals(snapshot.journal), ext: "json") 297 return record 298 } 299 300 private static func asset(for data: Data, ext: String) throws -> CKAsset { 301 let url = FileManager.default.temporaryDirectory 302 .appendingPathComponent(UUID().uuidString) 303 .appendingPathExtension(ext) 304 try data.write(to: url, options: .atomic) 305 return CKAsset(fileURL: url) 306 } 307 308 // MARK: - Materialization 309 310 /// The decoded payload of an inbound `Archive` record. 311 struct Payload { 312 let originalGameID: UUID 313 let archiveGameID: UUID 314 let title: String 315 let puzzleSource: String 316 let completedAt: Date 317 let completedBy: String? 318 /// The frozen solve time in whole seconds, or `nil` for archives written 319 /// before the field existed (their materialised game simply shows no time). 320 let solveSeconds: Int? 321 let cells: [Cell] 322 let journal: [DeviceJournal] 323 } 324 325 /// Builds the materialization payload directly from a local snapshot, 326 /// without round-tripping through CloudKit. Used to promote the archive on 327 /// revocation while still offline — the local game data is fully present, so 328 /// the cloud copy need not have landed back. 329 static func payload(from snapshot: Snapshot) -> Payload { 330 Payload( 331 originalGameID: snapshot.originalGameID, 332 archiveGameID: archiveGameID(for: snapshot.originalGameID), 333 title: snapshot.title, 334 puzzleSource: snapshot.puzzleSource, 335 completedAt: snapshot.completedAt, 336 completedBy: snapshot.completedBy, 337 solveSeconds: snapshot.solveSeconds, 338 cells: snapshot.cells, 339 journal: snapshot.journal 340 ) 341 } 342 343 static func payload(from record: CKRecord) -> Payload? { 344 guard record.recordType == recordType, 345 let originalString = record["originalGameID"] as? String, 346 let originalGameID = UUID(uuidString: originalString), 347 let archiveString = record["archiveGameID"] as? String, 348 let archiveGameID = UUID(uuidString: archiveString), 349 let completedAt = record["completedAt"] as? Date 350 else { return nil } 351 352 let puzzleSource = (record["puzzleSource"] as? CKAsset) 353 .flatMap { $0.fileURL } 354 .flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? "" 355 let cells = (record["cells"] as? CKAsset) 356 .flatMap { $0.fileURL } 357 .flatMap { try? Data(contentsOf: $0) } 358 .flatMap { try? decodeCells($0) } ?? [] 359 let journal = (record["journals"] as? CKAsset) 360 .flatMap { $0.fileURL } 361 .flatMap { try? Data(contentsOf: $0) } 362 .flatMap { try? decodeJournals($0) } ?? [] 363 364 return Payload( 365 originalGameID: originalGameID, 366 archiveGameID: archiveGameID, 367 title: record["title"] as? String ?? "", 368 puzzleSource: puzzleSource, 369 completedAt: completedAt, 370 completedBy: record["completedBy"] as? String, 371 solveSeconds: (record["solveSeconds"] as? Int64).map(Int.init), 372 cells: cells, 373 journal: journal 374 ) 375 } 376 377 /// Rebuilds a standalone completed, owned game from an archive payload, under 378 /// the derived `archiveGameID`. Idempotent: a second application of the same 379 /// (frozen) archive is a no-op once the row exists. The created row is never 380 /// enqueued for sync, so it pushes no Game/Moves/Player record — the 381 /// `Archive` record in the private zone remains its only cloud identity. 382 /// 383 /// Each contributing device's log is written as `sourceDeviceID`-tagged 384 /// `JournalEntity` rows and `replayCacheComplete` is set, so the existing 385 /// replay path (`GameStore.cachedRemoteJournals`) serves the full merged 386 /// timeline straight from Core Data — no shared zone to fetch from. 387 @discardableResult 388 static func materialize( 389 _ payload: Payload, 390 in ctx: NSManagedObjectContext 391 ) -> GameEntity? { 392 guard !payload.puzzleSource.isEmpty else { return nil } 393 let archiveID = payload.archiveGameID 394 395 let existing = NSFetchRequest<GameEntity>(entityName: "GameEntity") 396 existing.predicate = NSPredicate(format: "id == %@", archiveID as CVarArg) 397 existing.fetchLimit = 1 398 if let row = try? ctx.fetch(existing).first { return row } 399 400 let entity = GameEntity(context: ctx) 401 entity.id = archiveID 402 // A sentinel record name: distinct from the `game-` form so no sync 403 // path mistakes the archive for a pushable Game record, while staying 404 // non-nil for code that fetches games by `ckRecordName`. 405 entity.ckRecordName = recordName(forOriginalGameID: payload.originalGameID) 406 entity.ckZoneName = zoneID(forOriginalGameID: payload.originalGameID).zoneName 407 entity.ckZoneOwnerName = nil 408 entity.databaseScope = 0 409 entity.title = payload.title 410 entity.puzzleSource = payload.puzzleSource 411 entity.completedAt = payload.completedAt 412 entity.completedBy = payload.completedBy 413 // The frozen solve time the live clock reached; `PlayerRoster.solveTime` 414 // returns this for a materialised archive, which has no `timeLog` rows. 415 if let solveSeconds = payload.solveSeconds { 416 entity.finalSolveSeconds = NSNumber(value: solveSeconds) 417 } 418 entity.createdAt = payload.completedAt 419 entity.updatedAt = payload.completedAt 420 entity.archivedAt = payload.completedAt 421 entity.archiveGameID = archiveID 422 // Every contributor's log is captured below, so the replay cache is 423 // complete by construction — replay reads it locally, never the 424 // (now-gone) shared zone. 425 entity.replayCacheComplete = true 426 427 for cell in payload.cells { 428 let row = CellEntity(context: ctx) 429 row.game = entity 430 row.row = cell.row 431 row.col = cell.col 432 row.letter = cell.letter 433 row.markCode = cell.markCode 434 row.letterAuthorID = cell.letterAuthorID 435 } 436 437 // Each device's log is stored as `sourceDeviceID`-tagged rows so the 438 // replay reader treats every author — including the archiving user's own 439 // historical moves — as a cached contributor (the archived game has no 440 // *live* local journal to overlay). 441 for deviceJournal in payload.journal { 442 for value in deviceJournal.entries { 443 let row = JournalEntity(context: ctx) 444 row.game = entity 445 MovesJournal.assign(value, to: row, gameID: archiveID) 446 row.sourceAuthorID = deviceJournal.key.authorID 447 row.sourceDeviceID = deviceJournal.key.deviceID 448 } 449 } 450 451 return entity 452 } 453 }