crossmate

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

FriendZone.swift (5541B)


      1 import CryptoKit
      2 import Foundation
      3 
      4 /// Pure helpers for the friendship channel. A friendship is realised as one
      5 /// custom CloudKit zone (`friend-<pairKey>`) carrying a zone-wide `CKShare`
      6 /// with the other user added as a `.readWrite` participant. The zone lives in
      7 /// the *owner's* private database and appears in the *participant's* shared
      8 /// database; both sides can write `Ping` records into it.
      9 ///
     10 /// Everything here is deterministic and side-effect-free so both devices
     11 /// derive the same zone name and elect the same owner without coordination.
     12 /// The CloudKit lifecycle lives in `FriendController`.
     13 enum FriendZone {
     14     /// Zone-name prefix. The shared-DB sync paths branch on this to keep a
     15     /// friend zone from being mistaken for a `game-<UUID>` zone.
     16     static let zonePrefix = "friend-"
     17 
     18     /// Stable, symmetric key for the unordered pair of iCloud user record
     19     /// names. `pairKey(a, b) == pairKey(b, a)` and the same inputs always
     20     /// produce the same bounded-length string, so both devices independently
     21     /// derive the same friend-zone name.
     22     static func pairKey(_ a: String, _ b: String) -> String {
     23         let joined = [a, b].sorted().joined(separator: "\u{1}")
     24         let digest = SHA256.hash(data: Data(joined.utf8))
     25         return digest.map { String(format: "%02x", $0) }.joined()
     26     }
     27 
     28     /// Friend-zone name for a pair key. Valid CKRecordZone name: prefix plus
     29     /// 64 lowercase hex characters.
     30     static func zoneName(pairKey: String) -> String {
     31         "\(zonePrefix)\(pairKey)"
     32     }
     33 
     34     static func isFriendZone(_ zoneName: String) -> Bool {
     35         zoneName.hasPrefix(zonePrefix)
     36     }
     37 
     38     /// Owner election: the user whose record name sorts first creates and
     39     /// owns the zone; the other accepts the share. Deterministic on both
     40     /// devices. Equal IDs (same user) can never be friends — returns false.
     41     static func isOwner(localAuthorID: String, remoteAuthorID: String) -> Bool {
     42         localAuthorID != remoteAuthorID && localAuthorID < remoteAuthorID
     43     }
     44 
     45     /// Whether `localAuthorID` is the intended acceptor for a game-zone
     46     /// friendship bootstrap. `.friend` Pings are broadcast to every game
     47     /// participant, so a third collaborator must ignore a pairwise bootstrap
     48     /// meant for someone else instead of attempting to accept the CKShare.
     49     static func canAcceptBootstrap(_ payload: BootstrapPayload, localAuthorID: String?) -> Bool {
     50         guard let localAuthorID, !localAuthorID.isEmpty else { return false }
     51         guard localAuthorID != payload.ownerAuthorID else { return false }
     52         return pairKey(localAuthorID, payload.ownerAuthorID) == payload.pairKey
     53     }
     54 
     55     /// Payload carried in a `.friend` Ping (written into the *game* zone) so
     56     /// the non-owner can accept the friend-zone share without an out-of-band
     57     /// link.
     58     struct BootstrapPayload: Codable, Equatable {
     59         let friendShareURL: String
     60         let pairKey: String
     61         let ownerAuthorID: String
     62 
     63         func encodedString() -> String? {
     64             guard let data = try? JSONEncoder().encode(self) else { return nil }
     65             return String(data: data, encoding: .utf8)
     66         }
     67 
     68         static func decode(_ raw: String?) -> BootstrapPayload? {
     69             guard let raw, let data = raw.data(using: .utf8) else { return nil }
     70             return try? JSONDecoder().decode(BootstrapPayload.self, from: data)
     71         }
     72     }
     73 
     74     /// Payload carried in an `.invite` Ping (written into the *friend* zone)
     75     /// so the recipient can accept the game's `CKShare` from the "Invited"
     76     /// section without an out-of-band link.
     77     struct InvitePayload: Codable, Equatable {
     78         let gameShareURL: String
     79         /// The game's grid silhouette, as a `GridSilhouette`-encoded segment,
     80         /// so the recipient's "Invited" row can preview the puzzle's shape
     81         /// without a CloudKit round-trip — the internal-invite counterpart to
     82         /// the silhouette segment carried in share links. `nil` for non-square
     83         /// grids (which get no preview) and for invites from older senders.
     84         let gridSilhouette: String?
     85         /// The puzzle's full XD source. The recipient already syncs the friend
     86         /// zone, so this arrives with the Ping and lets the accept path build a
     87         /// playable game immediately — the shared-zone fetch then only updates
     88         /// it. Even an oversized Sunday is a few KB, well under CloudKit's
     89         /// per-record limit, and `payload` is not an indexed field. `nil` for
     90         /// invites from older senders, which fall back to the fetch.
     91         let puzzleSource: String?
     92 
     93         // An explicit init keeps the optionals out of the inline default
     94         // (which would exclude them from Codable) while letting existing call
     95         // sites omit them; a missing JSON key decodes to `nil`.
     96         init(gameShareURL: String, gridSilhouette: String? = nil, puzzleSource: String? = nil) {
     97             self.gameShareURL = gameShareURL
     98             self.gridSilhouette = gridSilhouette
     99             self.puzzleSource = puzzleSource
    100         }
    101 
    102         func encodedString() -> String? {
    103             guard let data = try? JSONEncoder().encode(self) else { return nil }
    104             return String(data: data, encoding: .utf8)
    105         }
    106 
    107         static func decode(_ raw: String?) -> InvitePayload? {
    108             guard let raw, let data = raw.data(using: .utf8) else { return nil }
    109             return try? JSONDecoder().decode(InvitePayload.self, from: data)
    110         }
    111     }
    112 }