crossmate

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

RecordSerializer.swift (19521B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 /// Pure-function helpers for converting between the app's Core Data / in-memory
      6 /// models and CloudKit `CKRecord` objects. Stateless — all context is passed in.
      7 enum RecordSerializer {
      8 
      9     // MARK: - Device identity
     10 
     11     /// A stable per-device identifier appended to move and snapshot record
     12     /// names to prevent two devices owned by the same iCloud user from
     13     /// producing identical record names when both assign the same Lamport
     14     /// clock value while offline.
     15     ///
     16     /// Stored in UserDefaults so it survives app restarts but resets on
     17     /// reinstall (which is fine — a reinstalled app has no local moves to
     18     /// conflict with).
     19     static let localDeviceID: String = {
     20         let key = "crossmate.localDeviceID"
     21         if let stored = UserDefaults.standard.string(forKey: key) {
     22             return stored
     23         }
     24         let new = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
     25         UserDefaults.standard.set(new, forKey: key)
     26         return new
     27     }()
     28 
     29     // MARK: - Record names
     30 
     31     static func recordName(forGameID gameID: UUID) -> String {
     32         "game-\(gameID.uuidString)"
     33     }
     34 
     35     /// One Moves record per `(game, authorID, deviceID)`. Each device only
     36     /// writes to its own slot, so there are no write-write conflicts on the
     37     /// `cells` field.
     38     static func recordName(
     39         forMovesInGame gameID: UUID,
     40         authorID: String,
     41         deviceID: String
     42     ) -> String {
     43         "moves-\(gameID.uuidString)-\(authorID)-\(deviceID)"
     44     }
     45 
     46     /// One player record per (game, author). Each participant only ever
     47     /// writes to their own slot, so there are no write-write conflicts on
     48     /// the field.
     49     static func recordName(forPlayerInGame gameID: UUID, authorID: String) -> String {
     50         "player-\(gameID.uuidString)-\(authorID)"
     51     }
     52 
     53     /// One Ping record per event. The event timestamp (ms since epoch) makes
     54     /// the name unique across events and devices, so repeated pings from the
     55     /// same author for the same game don't collide.
     56     static func recordName(
     57         forPingInGame gameID: UUID,
     58         authorID: String,
     59         eventTimestampMs: Int64
     60     ) -> String {
     61         "ping-\(gameID.uuidString)-\(authorID)-\(eventTimestampMs)"
     62     }
     63 
     64     // MARK: - Zone
     65 
     66     /// Zone ID for a per-game zone. `ownerName` defaults to the current user
     67     /// placeholder; pass an explicit value for shared games where the zone is
     68     /// owned by another iCloud account.
     69     static func zoneID(
     70         for gameID: UUID,
     71         ownerName: String = CKCurrentUserDefaultName
     72     ) -> CKRecordZone.ID {
     73         CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName)
     74     }
     75 
     76     // MARK: - Moves record building
     77 
     78     static func movesRecord(
     79         from view: MovesValue,
     80         zone: CKRecordZone.ID,
     81         systemFields: Data?
     82     ) throws -> CKRecord {
     83         let movesName = recordName(
     84             forMovesInGame: view.gameID,
     85             authorID: view.authorID,
     86             deviceID: view.deviceID
     87         )
     88         let record = restoreOrCreate(
     89             recordType: "Moves",
     90             recordName: movesName,
     91             zone: zone,
     92             systemFields: systemFields
     93         )
     94 
     95         record["authorID"] = view.authorID as CKRecordValue
     96         record["deviceID"] = view.deviceID as CKRecordValue
     97         record["updatedAt"] = view.updatedAt as CKRecordValue
     98         record["cells"] = try MovesCodec.encode(view.cells) as CKRecordValue
     99 
    100         return record
    101     }
    102 
    103     static func gameRecord(
    104         from entity: GameEntity,
    105         recordID: CKRecord.ID,
    106         includePuzzleSource: Bool
    107     ) -> CKRecord? {
    108         guard entity.ckRecordName != nil else { return nil }
    109         let record: CKRecord
    110         if let fields = entity.ckSystemFields,
    111            let restored = decodeRecord(from: fields) {
    112             record = restored
    113         } else {
    114             record = CKRecord(recordType: "Game", recordID: recordID)
    115         }
    116         populateGameRecord(record, from: entity, includePuzzleSource: includePuzzleSource)
    117         return record
    118     }
    119 
    120     static func populateGameRecord(
    121         _ record: CKRecord,
    122         from entity: GameEntity,
    123         includePuzzleSource: Bool
    124     ) {
    125         record["title"] = entity.title as CKRecordValue?
    126         record["completedAt"] = entity.completedAt as CKRecordValue?
    127         // Owner-side share marker. Propagated so other owner-devices can flip
    128         // their `isShared` flag without reading the zone's CKShare directly.
    129         record["shareRecordName"] = entity.ckShareRecordName as CKRecordValue?
    130         guard includePuzzleSource, let source = entity.puzzleSource else { return }
    131         let url = FileManager.default.temporaryDirectory
    132             .appendingPathComponent(UUID().uuidString)
    133             .appendingPathExtension("xd")
    134         try? source.write(to: url, atomically: true, encoding: .utf8)
    135         record["puzzleSource"] = CKAsset(fileURL: url)
    136     }
    137 
    138     /// Builds a freshly-minted Ping record. Pings are write-once — they have
    139     /// no Core Data equivalent and no system-fields archive.
    140     /// - `authorID` lets receivers filter out self-sends.
    141     /// - `playerName` and `puzzleTitle` let receivers render the alert body.
    142     /// - `kind` distinguishes session/join/win/check/reveal events.
    143     /// - `scope` is set only for check/reveal kinds.
    144     static func pingRecord(
    145         gameID: UUID,
    146         authorID: String,
    147         playerName: String,
    148         puzzleTitle: String,
    149         eventTimestampMs: Int64,
    150         kind: PingKind,
    151         scope: PingScope?,
    152         zone: CKRecordZone.ID
    153     ) -> CKRecord {
    154         let name = recordName(
    155             forPingInGame: gameID,
    156             authorID: authorID,
    157             eventTimestampMs: eventTimestampMs
    158         )
    159         let recordID = CKRecord.ID(recordName: name, zoneID: zone)
    160         let record = CKRecord(recordType: "Ping", recordID: recordID)
    161         record["authorID"] = authorID as CKRecordValue
    162         record["playerName"] = playerName as CKRecordValue
    163         record["puzzleTitle"] = puzzleTitle as CKRecordValue
    164         record["kind"] = kind.rawValue as CKRecordValue
    165         if let scope {
    166             record["scope"] = scope.rawValue as CKRecordValue
    167         }
    168         return record
    169     }
    170 
    171     static func playerRecord(
    172         gameID: UUID,
    173         authorID: String,
    174         name: String,
    175         updatedAt: Date,
    176         selection: PlayerSelection?,
    177         zone: CKRecordZone.ID,
    178         systemFields: Data?
    179     ) -> CKRecord {
    180         let recordName = recordName(forPlayerInGame: gameID, authorID: authorID)
    181         let record = restoreOrCreate(
    182             recordType: "Player",
    183             recordName: recordName,
    184             zone: zone,
    185             systemFields: systemFields
    186         )
    187 
    188         record["authorID"] = authorID as CKRecordValue
    189         record["name"] = name as CKRecordValue
    190         record["updatedAt"] = updatedAt as CKRecordValue
    191         if let selection {
    192             record["selRow"] = Int64(selection.row) as CKRecordValue
    193             record["selCol"] = Int64(selection.col) as CKRecordValue
    194             record["selDir"] = Int64(selection.direction.rawValue) as CKRecordValue
    195         } else {
    196             record["selRow"] = nil
    197             record["selCol"] = nil
    198             record["selDir"] = nil
    199         }
    200 
    201         return record
    202     }
    203 
    204     /// Reads `selRow`/`selCol`/`selDir` off an inbound Player record. Returns
    205     /// `nil` if any field is missing — the peer either hasn't published a
    206     /// selection yet or has cleared theirs (e.g. left the puzzle view).
    207     static func parsePlayerSelection(from record: CKRecord) -> PlayerSelection? {
    208         guard let row = record["selRow"] as? Int64,
    209               let col = record["selCol"] as? Int64,
    210               let dirRaw = record["selDir"] as? Int64,
    211               let direction = PlayerSelection.Direction(rawValue: Int(dirRaw))
    212         else { return nil }
    213         return PlayerSelection(row: Int(row), col: Int(col), direction: direction)
    214     }
    215 
    216     /// Parses an incoming `Player` record name back into its `(gameID,
    217     /// authorID)` components. Returns `nil` if the name doesn't match the
    218     /// `player-<UUID>-<authorID>` shape.
    219     static func parsePlayerRecordName(_ name: String) -> (UUID, String)? {
    220         guard name.hasPrefix("player-") else { return nil }
    221         let rest = name.dropFirst("player-".count)
    222         let uuidLength = 36
    223         guard rest.count > uuidLength,
    224               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    225         else { return nil }
    226         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    227         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    228         let authorPart = String(rest.suffix(from: rest.index(rest.startIndex, offsetBy: uuidLength + 1)))
    229         guard !authorPart.isEmpty else { return nil }
    230         return (gameID, authorPart)
    231     }
    232 
    233     /// Parses an incoming `Moves` CKRecord into a `MovesValue`. Returns `nil`
    234     /// if the record name doesn't match the `moves-<gameUUID>-<authorID>-<deviceID>`
    235     /// shape or the cells payload fails to decode.
    236     static func parseMovesRecord(_ record: CKRecord) -> MovesValue? {
    237         guard record.recordType == "Moves" else { return nil }
    238         guard let (gameID, authorID, deviceID) = parseMovesRecordName(
    239             record.recordID.recordName
    240         ) else { return nil }
    241         guard let data = record["cells"] as? Data,
    242               let cells = try? MovesCodec.decode(data)
    243         else { return nil }
    244         let updatedAt = record["updatedAt"] as? Date
    245             ?? record.modificationDate
    246             ?? Date()
    247         return MovesValue(
    248             gameID: gameID,
    249             authorID: authorID,
    250             deviceID: deviceID,
    251             cells: cells,
    252             updatedAt: updatedAt
    253         )
    254     }
    255 
    256     /// Parses `moves-<gameUUID>-<authorID>-<deviceID>` into its three parts.
    257     /// `deviceID` is the suffix after the final `-`; `authorID` may itself
    258     /// contain dashes (e.g. CloudKit user record names with no dashes today,
    259     /// but we don't want to assume).
    260     static func parseMovesRecordName(_ name: String) -> (UUID, String, String)? {
    261         let prefix = "moves-"
    262         guard name.hasPrefix(prefix) else { return nil }
    263         let rest = name.dropFirst(prefix.count)
    264         let uuidLength = 36
    265         guard rest.count > uuidLength,
    266               rest[rest.index(rest.startIndex, offsetBy: uuidLength)] == "-"
    267         else { return nil }
    268         let uuidPart = String(rest[rest.startIndex..<rest.index(rest.startIndex, offsetBy: uuidLength)])
    269         guard let gameID = UUID(uuidString: uuidPart) else { return nil }
    270         let afterUUID = rest.index(rest.startIndex, offsetBy: uuidLength + 1)
    271         let tail = rest[afterUUID...]
    272         guard let lastDash = tail.lastIndex(of: "-") else { return nil }
    273         let authorID = String(tail[tail.startIndex..<lastDash])
    274         let deviceID = String(tail[tail.index(after: lastDash)...])
    275         guard !authorID.isEmpty, !deviceID.isEmpty else { return nil }
    276         return (gameID, authorID, deviceID)
    277     }
    278 
    279     // MARK: - Applying incoming CKRecords to Core Data
    280 
    281     /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if
    282     /// none exists yet. Moves and Player records can arrive in a different
    283     /// fetch batch than the Game record that created the zone — on
    284     /// a fresh device CKSyncEngine paginates the initial pull and there is no
    285     /// guarantee that Game comes first. Without this stub the parent lookup
    286     /// fails, the inbound record is dropped, but CKSyncEngine still advances
    287     /// its change token, so the gap is invisible until the next state reset.
    288     /// The stub uses empty `title` / `puzzleSource` so `GameSummary.init?`
    289     /// filters it out of the library until `applyGameRecord` arrives with
    290     /// the real metadata and updates the same row (matched by `ckRecordName`).
    291     static func ensureGameEntity(
    292         forGameID gameID: UUID,
    293         zoneID: CKRecordZone.ID,
    294         in ctx: NSManagedObjectContext
    295     ) -> GameEntity {
    296         let name = recordName(forGameID: gameID)
    297         let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    298         req.predicate = NSPredicate(format: "ckRecordName == %@", name)
    299         req.fetchLimit = 1
    300         if let existing = try? ctx.fetch(req).first { return existing }
    301         let entity = GameEntity(context: ctx)
    302         entity.id = gameID
    303         entity.ckRecordName = name
    304         entity.ckZoneName = zoneID.zoneName
    305         let ownerName = zoneID.ownerName
    306         let isOwner = ownerName == CKCurrentUserDefaultName
    307         entity.ckZoneOwnerName = isOwner ? nil : ownerName
    308         entity.databaseScope = isOwner ? 0 : 1
    309         entity.title = ""
    310         entity.puzzleSource = ""
    311         entity.createdAt = Date()
    312         entity.updatedAt = Date()
    313         return entity
    314     }
    315 
    316     static func applyGameRecord(
    317         _ record: CKRecord,
    318         to context: NSManagedObjectContext,
    319         databaseScope: Int16 = 0
    320     ) -> GameEntity {
    321         let recordName = record.recordID.recordName
    322         let entity = fetchOrCreate(
    323             entityName: "GameEntity",
    324             recordName: recordName,
    325             in: context
    326         ) as! GameEntity
    327 
    328         // Recover the UUID from the record name ("game-<UUID>") so the
    329         // library query, which filters on `entity.id`, doesn't silently drop
    330         // newly-synced games.
    331         if entity.id == nil {
    332             let uuidString = String(recordName.dropFirst("game-".count))
    333             entity.id = UUID(uuidString: uuidString)
    334         }
    335 
    336         // Seed createdAt/updatedAt from the server record so the library
    337         // can order newly-arrived games. The CKRecord timestamps are the
    338         // source of truth when we don't have a local creation event.
    339         if entity.createdAt == nil {
    340             entity.createdAt = record.creationDate ?? Date()
    341         }
    342         entity.updatedAt = record.modificationDate ?? entity.updatedAt ?? Date()
    343 
    344         entity.ckRecordName = recordName
    345         entity.ckSystemFields = encodeSystemFields(of: record)
    346         entity.title = record["title"] as? String ?? entity.title
    347         entity.completedAt = record["completedAt"] as? Date
    348         entity.databaseScope = databaseScope
    349         // Owner-side share marker — set on the device that created the share
    350         // and round-tripped via the Game record so other owner-devices learn
    351         // the game is shared. On participant devices `databaseScope == 1`
    352         // already implies shared, but keeping the field in sync is harmless.
    353         if let shareRecordName = record["shareRecordName"] as? String {
    354             entity.ckShareRecordName = shareRecordName
    355         }
    356 
    357         // Persist the zone identity so outbound moves use the right zone ID.
    358         entity.ckZoneName = record.recordID.zoneID.zoneName
    359         let ownerName = record.recordID.zoneID.ownerName
    360         entity.ckZoneOwnerName = ownerName == CKCurrentUserDefaultName ? nil : ownerName
    361 
    362         if let asset = record["puzzleSource"] as? CKAsset,
    363            let fileURL = asset.fileURL,
    364            let source = try? String(contentsOf: fileURL, encoding: .utf8) {
    365             entity.puzzleSource = source
    366             if let xd = try? XD.parse(source) {
    367                 entity.puzzleCmVersion = Int64(XD.currentCmVersion)
    368                 entity.populateCachedSummaryFields(from: Puzzle(xd: xd))
    369             }
    370         }
    371 
    372         return entity
    373     }
    374 
    375     /// Upserts the `MovesEntity` for `value`. The cells blob is taken straight
    376     /// off the record so any forward-compat fields the encoder added are
    377     /// preserved verbatim. Bumps the parent `GameEntity.updatedAt` if the
    378     /// record is fresher.
    379     static func applyMovesRecord(
    380         _ record: CKRecord,
    381         value: MovesValue,
    382         to ctx: NSManagedObjectContext,
    383         localAuthorID: String? = nil
    384     ) {
    385         let ckName = record.recordID.recordName
    386         let req = NSFetchRequest<MovesEntity>(entityName: "MovesEntity")
    387         req.predicate = NSPredicate(format: "ckRecordName == %@", ckName)
    388         req.fetchLimit = 1
    389 
    390         let entity: MovesEntity
    391         let foundExisting: Bool
    392         if let existing = try? ctx.fetch(req).first {
    393             entity = existing
    394             foundExisting = true
    395         } else {
    396             let game = ensureGameEntity(
    397                 forGameID: value.gameID,
    398                 zoneID: record.recordID.zoneID,
    399                 in: ctx
    400             )
    401             entity = MovesEntity(context: ctx)
    402             entity.game = game
    403             foundExisting = false
    404         }
    405 
    406         // Always adopt system fields so future saves target the server's
    407         // current change tag. If this is our own per-device row and it already
    408         // exists locally, the local value state is authoritative; tokenless
    409         // push-driven direct fetches can re-deliver an older server copy while
    410         // newer edits are still queued for upload.
    411         entity.ckRecordName = ckName
    412         entity.ckSystemFields = encodeSystemFields(of: record)
    413         entity.authorID = value.authorID
    414         entity.deviceID = value.deviceID
    415         let isLocalDeviceRow = value.authorID == localAuthorID
    416             && value.deviceID == localDeviceID
    417         guard !foundExisting || !isLocalDeviceRow else { return }
    418         entity.updatedAt = value.updatedAt
    419         entity.cells = (record["cells"] as? Data) ?? Data()
    420 
    421         if let game = entity.game,
    422            game.updatedAt.map({ $0 < value.updatedAt }) ?? true {
    423             game.updatedAt = value.updatedAt
    424         }
    425     }
    426 
    427     // MARK: - System fields encode/decode
    428 
    429     static func encodeSystemFields(of record: CKRecord) -> Data? {
    430         let coder = NSKeyedArchiver(requiringSecureCoding: true)
    431         record.encodeSystemFields(with: coder)
    432         coder.finishEncoding()
    433         return coder.encodedData
    434     }
    435 
    436     static func decodeRecord(from data: Data) -> CKRecord? {
    437         guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
    438         coder.requiresSecureCoding = true
    439         let record = CKRecord(coder: coder)
    440         coder.finishDecoding()
    441         return record
    442     }
    443 
    444     // MARK: - Private helpers
    445 
    446     /// Restores a `CKRecord` from archived system fields (preserving the
    447     /// server change tag) or creates a fresh one if no archive is available.
    448     private static func restoreOrCreate(
    449         recordType: String,
    450         recordName: String,
    451         zone: CKRecordZone.ID,
    452         systemFields: Data?
    453     ) -> CKRecord {
    454         if let data = systemFields, let restored = decodeRecord(from: data) {
    455             return restored
    456         }
    457         let recordID = CKRecord.ID(recordName: recordName, zoneID: zone)
    458         return CKRecord(recordType: recordType, recordID: recordID)
    459     }
    460 
    461     private static func fetchOrCreate(
    462         entityName: String,
    463         recordName: String,
    464         in context: NSManagedObjectContext
    465     ) -> NSManagedObject {
    466         let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
    467         request.predicate = NSPredicate(format: "ckRecordName == %@", recordName)
    468         request.fetchLimit = 1
    469         if let existing = try? context.fetch(request).first {
    470             return existing
    471         }
    472         return NSEntityDescription.insertNewObject(forEntityName: entityName, into: context)
    473     }
    474 
    475 }