crossmate

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

ShareController.swift (14604B)


      1 import CloudKit
      2 import CoreData
      3 import Foundation
      4 
      5 /// Manages the lifecycle of `CKShare` objects for per-game zones. Responsible
      6 /// for creating zone-scoped shares and saving them to CloudKit, refreshing
      7 /// existing shares on re-present, and letting participants leave a shared game.
      8 @MainActor
      9 final class ShareController {
     10     private static let zoneWideShareRecordName = "cloudkit.zoneshare"
     11 
     12     let container: CKContainer
     13     private let persistence: PersistenceController
     14     private let syncEngine: SyncEngine
     15     private let syncMonitor: SyncMonitor?
     16 
     17     /// Fired after `persistShareName` has saved the local entity's
     18     /// `ckShareRecordName`, so dependent state (e.g. the open game's mutator
     19     /// `isShared` flag) can flip without waiting for the user to re-open.
     20     var onShareSaved: (@MainActor (UUID) -> Void)?
     21 
     22     enum ShareError: Error {
     23         case gameNotFound
     24         case invalidShareRecord
     25         case notAnOwner
     26         case invalidGameRecord
     27         case missingShareURL
     28     }
     29 
     30     init(
     31         container: CKContainer,
     32         persistence: PersistenceController,
     33         syncEngine: SyncEngine,
     34         syncMonitor: SyncMonitor? = nil
     35     ) {
     36         self.container = container
     37         self.persistence = persistence
     38         self.syncEngine = syncEngine
     39         self.syncMonitor = syncMonitor
     40     }
     41 
     42     /// Returns the `CKShare` and container for `UICloudSharingController`'s
     43     /// preparation handler. For a first-time share, the returned share is
     44     /// *unsaved* — `UICloudSharingController` saves it when the user submits
     45     /// participants. Call `persistShareName(_:for:)` from the controller's
     46     /// `didSaveShare` delegate callback to record the saved share's name.
     47     /// For an existing share, the saved share is fetched and returned.
     48     func prepareShare(for gameID: UUID) async throws -> (CKShare, CKContainer) {
     49         let share = try await prepareShareRecord(for: gameID, publicPermission: .none)
     50         return (share, container)
     51     }
     52 
     53     /// Creates or updates the game's CloudKit share as a public collaboration
     54     /// link and returns the generated URL. This avoids the participant
     55     /// management UI and lets Crossmate capture the CloudKit save error
     56     /// directly when link creation fails.
     57     func createShareLink(for gameID: UUID) async throws -> URL {
     58         syncMonitor?.recordStart("create share link")
     59         do {
     60             let share = try await prepareShareRecord(for: gameID, publicPermission: .readWrite)
     61             let savedShare: CKShare
     62             do {
     63                 savedShare = try await saveShareForLink(share, for: gameID)
     64             } catch let error as CKError where error.code == .serverRecordChanged {
     65                 savedShare = try await recoverShareLinkAfterSaveConflict(error, for: gameID)
     66             }
     67             let url = try shareURL(from: savedShare)
     68             syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)")
     69             syncMonitor?.recordSuccess("create share link")
     70             return url
     71         } catch {
     72             syncMonitor?.recordError("create share link", error)
     73             throw error
     74         }
     75     }
     76 
     77     /// Returns the saved public share URL for a game, if Crossmate already
     78     /// knows about its `CKShare`. Stale local share references are cleared so
     79     /// the caller can safely offer to create a fresh link.
     80     func existingShareLink(for gameID: UUID) async throws -> URL? {
     81         let ctx = persistence.viewContext
     82         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
     83         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
     84         request.fetchLimit = 1
     85         guard let entity = try ctx.fetch(request).first else {
     86             throw ShareError.gameNotFound
     87         }
     88         guard entity.databaseScope == 0 else {
     89             throw ShareError.notAnOwner
     90         }
     91         guard let existingName = entity.ckShareRecordName else {
     92             let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
     93             do {
     94                 let share = try await fetchExistingShare(
     95                     recordName: Self.zoneWideShareRecordName,
     96                     zoneName: zoneName
     97                 )
     98                 entity.ckShareRecordName = share.recordID.recordName
     99                 try ctx.save()
    100                 return share.url
    101             } catch let error as CKError where isMissingShare(error) {
    102                 return nil
    103             }
    104         }
    105 
    106         do {
    107             let share = try await fetchExistingShare(
    108                 recordName: existingName,
    109                 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
    110             )
    111             return share.url
    112         } catch let error as CKError where error.code == .unknownItem {
    113             entity.ckShareRecordName = nil
    114             try ctx.save()
    115             return nil
    116         }
    117     }
    118 
    119     private func prepareShareRecord(
    120         for gameID: UUID,
    121         publicPermission: CKShare.ParticipantPermission
    122     ) async throws -> CKShare {
    123         let ctx = persistence.viewContext
    124         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    125         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    126         request.fetchLimit = 1
    127         guard let entity = try ctx.fetch(request).first else {
    128             throw ShareError.gameNotFound
    129         }
    130         guard entity.databaseScope == 0 else {
    131             throw ShareError.notAnOwner
    132         }
    133 
    134         if let existingName = entity.ckShareRecordName {
    135             do {
    136                 let existing = try await fetchExistingShare(
    137                     recordName: existingName,
    138                     zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
    139                 )
    140                 return configureShare(existing, title: entity.title, publicPermission: publicPermission)
    141             } catch let error as CKError where error.code == .unknownItem {
    142                 entity.ckShareRecordName = nil
    143                 try ctx.save()
    144             }
    145         }
    146 
    147         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    148         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
    149 
    150         // Create the zone directly rather than going through CKSyncEngine.sendChanges(),
    151         // which can block on post-reset state (stale tokens, in-flight operations).
    152         // Zone creation is idempotent so this is safe even if the engine already created it.
    153         try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
    154             let op = CKModifyRecordZonesOperation(
    155                 recordZonesToSave: [CKRecordZone(zoneID: zoneID)],
    156                 recordZoneIDsToDelete: nil
    157             )
    158             op.qualityOfService = .userInitiated
    159             op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
    160             self.container.privateCloudDatabase.add(op)
    161         }
    162 
    163         if let existing = try await fetchZoneWideShareIfPresent(zoneName: zoneName) {
    164             entity.ckShareRecordName = existing.recordID.recordName
    165             try ctx.save()
    166             return configureShare(existing, title: entity.title, publicPermission: publicPermission)
    167         }
    168 
    169         try await ensureGameRecordExists(for: entity, in: zoneID)
    170 
    171         let share = CKShare(recordZoneID: zoneID)
    172         return configureShare(share, title: entity.title, publicPermission: publicPermission)
    173     }
    174 
    175     /// Records the share's CloudKit record name on the local entity so future
    176     /// invocations of `prepareShare` fetch the existing share. Also enqueues a
    177     /// Game record push so other owner-devices receive the share marker via
    178     /// `RecordSerializer.applyGameRecord` and flip their `isShared` flag.
    179     /// Idempotent.
    180     func persistShareName(_ recordName: String, for gameID: UUID) async throws {
    181         let ctx = persistence.viewContext
    182         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    183         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    184         request.fetchLimit = 1
    185         guard let entity = try ctx.fetch(request).first else { return }
    186         guard entity.ckShareRecordName != recordName else { return }
    187         entity.ckShareRecordName = recordName
    188         try ctx.save()
    189         if let ckRecordName = entity.ckRecordName {
    190             await syncEngine.enqueueGame(ckRecordName: ckRecordName)
    191         }
    192         onShareSaved?(gameID)
    193     }
    194 
    195     /// Removes the current user's participation from a shared game and deletes
    196     /// the local entity. No-ops if the game is not a shared (participant) game.
    197     func leaveShare(gameID: UUID) async throws {
    198         let ctx = persistence.viewContext
    199         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    200         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    201         request.fetchLimit = 1
    202         guard let entity = try ctx.fetch(request).first,
    203               entity.databaseScope == 1 else { return }
    204 
    205         let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
    206         let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
    207         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
    208         let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID)
    209 
    210         // A participant leaves a zone-wide share by deleting the CKShare record
    211         // from the shared database; deleting the zone itself is rejected with
    212         // "Zone delete not allowed".
    213         do {
    214             try await container.sharedCloudDatabase.deleteRecord(withID: shareID)
    215         } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound {
    216             // Already gone — proceed to clean up local state.
    217         }
    218 
    219         ctx.delete(entity)
    220         try ctx.save()
    221     }
    222 
    223     // MARK: - Helpers
    224 
    225     private func fetchExistingShare(
    226         recordName: String,
    227         zoneName: String
    228     ) async throws -> CKShare {
    229         let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
    230         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
    231         let record = try await container.privateCloudDatabase.record(for: recordID)
    232         guard let share = record as? CKShare else {
    233             throw ShareError.invalidShareRecord
    234         }
    235         return share
    236     }
    237 
    238     private func fetchZoneWideShareIfPresent(zoneName: String) async throws -> CKShare? {
    239         do {
    240             return try await fetchExistingShare(
    241                 recordName: Self.zoneWideShareRecordName,
    242                 zoneName: zoneName
    243             )
    244         } catch let error as CKError where isMissingShare(error) {
    245             return nil
    246         }
    247     }
    248 
    249     private func isMissingShare(_ error: CKError) -> Bool {
    250         error.code == .unknownItem || error.code == .zoneNotFound
    251     }
    252 
    253     @discardableResult
    254     private func configureShare(
    255         _ share: CKShare,
    256         title: String?,
    257         publicPermission: CKShare.ParticipantPermission
    258     ) -> CKShare {
    259         share.publicPermission = publicPermission
    260         share[CKShare.SystemFieldKey.title] = title as CKRecordValue?
    261         return share
    262     }
    263 
    264     private func saveShareForLink(_ share: CKShare, for gameID: UUID) async throws -> CKShare {
    265         let savedRecord = try await container.privateCloudDatabase.save(share)
    266         guard let savedShare = savedRecord as? CKShare else {
    267             throw ShareError.invalidShareRecord
    268         }
    269         try await persistShareName(savedShare.recordID.recordName, for: gameID)
    270         return savedShare
    271     }
    272 
    273     private func shareURL(from share: CKShare) throws -> URL {
    274         guard let url = share.url else {
    275             throw ShareError.missingShareURL
    276         }
    277         return url
    278     }
    279 
    280     private func recoverShareLinkAfterSaveConflict(
    281         _ error: CKError,
    282         for gameID: UUID
    283     ) async throws -> CKShare {
    284         let ctx = persistence.viewContext
    285         let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
    286         request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
    287         request.fetchLimit = 1
    288         guard let entity = try ctx.fetch(request).first else {
    289             throw ShareError.gameNotFound
    290         }
    291 
    292         let share: CKShare
    293         if let serverShare = (error as NSError).userInfo[CKRecordChangedErrorServerRecordKey] as? CKShare {
    294             share = serverShare
    295         } else {
    296             share = try await fetchExistingShare(
    297                 recordName: Self.zoneWideShareRecordName,
    298                 zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
    299             )
    300         }
    301 
    302         configureShare(share, title: entity.title, publicPermission: .readWrite)
    303         return try await saveShareForLink(share, for: gameID)
    304     }
    305 
    306     /// CloudKit requires the initial records covered by a new share to already
    307     /// exist on the server or be saved with the share. `CKShareTransferRepresentation`
    308     /// only returns the share, so save the root game record before handing the
    309     /// zone-wide share to the system UI.
    310     private func ensureGameRecordExists(
    311         for entity: GameEntity,
    312         in zoneID: CKRecordZone.ID
    313     ) async throws {
    314         guard let recordName = entity.ckRecordName else {
    315             throw ShareError.invalidGameRecord
    316         }
    317         let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
    318         let record: CKRecord
    319         let includePuzzleSource: Bool
    320 
    321         do {
    322             record = try await container.privateCloudDatabase.record(for: recordID)
    323             includePuzzleSource = record["puzzleSource"] == nil
    324         } catch let error as CKError where error.code == .unknownItem {
    325             guard let newRecord = RecordSerializer.gameRecord(
    326                 from: entity,
    327                 recordID: recordID,
    328                 includePuzzleSource: true
    329             ) else {
    330                 throw ShareError.invalidGameRecord
    331             }
    332             record = newRecord
    333             includePuzzleSource = true
    334         }
    335 
    336         RecordSerializer.populateGameRecord(
    337             record,
    338             from: entity,
    339             includePuzzleSource: includePuzzleSource
    340         )
    341         let saved = try await container.privateCloudDatabase.save(record)
    342         entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: saved)
    343         entity.lastSyncedAt = Date()
    344         if entity.ckZoneName == nil {
    345             entity.ckZoneName = zoneID.zoneName
    346         }
    347         try persistence.viewContext.save()
    348     }
    349 }