crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }