crossmate

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

FriendZone.swift (9095B)


      1 import CloudKit
      2 import CryptoKit
      3 import Foundation
      4 
      5 /// Pure helpers for the friendship channel. A friendship is realised as *two*
      6 /// mailbox zones, both named `friend-<pairKey>` and distinguished by owner:
      7 /// each user owns one (their inbox, in their private database) and is a
      8 /// `.readWrite` participant in the other's (their outbox, appearing in their
      9 /// shared database). You write to the friend by writing into their inbox (your
     10 /// outbox); you receive by reading your own inbox. Blocking is a permission
     11 /// downgrade on the inbox *you own*, so it is server-enforced and reversible.
     12 ///
     13 /// Everything here is deterministic and side-effect-free so both devices
     14 /// derive the same zone names without coordination. The CloudKit lifecycle
     15 /// lives in `FriendController`.
     16 enum FriendZone {
     17     /// Zone-name prefix. The shared-DB sync paths branch on this to keep a
     18     /// friend zone from being mistaken for a `game-<UUID>` zone.
     19     static let zonePrefix = "friend-"
     20 
     21     /// Stable, symmetric key for the unordered pair of iCloud user record
     22     /// names. `pairKey(a, b) == pairKey(b, a)` and the same inputs always
     23     /// produce the same bounded-length string, so both devices independently
     24     /// derive the same friend-zone name.
     25     static func pairKey(_ a: String, _ b: String) -> String {
     26         let joined = [a, b].sorted().joined(separator: "\u{1}")
     27         let digest = SHA256.hash(data: Data(joined.utf8))
     28         return digest.map { String(format: "%02x", $0) }.joined()
     29     }
     30 
     31     /// Friend-zone name for a pair key. Valid CKRecordZone name: prefix plus
     32     /// 64 lowercase hex characters.
     33     static func zoneName(pairKey: String) -> String {
     34         "\(zonePrefix)\(pairKey)"
     35     }
     36 
     37     static func isFriendZone(_ zoneName: String) -> Bool {
     38         zoneName.hasPrefix(zonePrefix)
     39     }
     40 
     41     /// The mailbox this device owns for the pair — its *inbox*. The friend
     42     /// writes invites / name / key records into it; this device reads them. It
     43     /// lives in this device's private database, so the owner name is the
     44     /// current-user default. (`scope == 0`.)
     45     static func inboxZoneID(pairKey: String) -> CKRecordZone.ID {
     46         CKRecordZone.ID(zoneName: zoneName(pairKey: pairKey), ownerName: CKCurrentUserDefaultName)
     47     }
     48 
     49     /// The friend's mailbox — this device's *outbox*. This device is a
     50     /// `.readWrite` participant and writes invites / name / key records here for
     51     /// the friend to read. It appears in this device's shared database, owned by
     52     /// the friend, whose CloudKit user-record name is `friendAuthorID` — which
     53     /// is exactly the shared-zone owner name. (`scope == 1`.)
     54     static func outboxZoneID(pairKey: String, friendAuthorID: String) -> CKRecordZone.ID {
     55         CKRecordZone.ID(zoneName: zoneName(pairKey: pairKey), ownerName: friendAuthorID)
     56     }
     57 
     58     // The two bootstrap halves are independent and each rides a Ping that is
     59     // re-fetched on every sync, so each needs its own idempotency guard. These
     60     // are per-device markers: a sibling or restored device may still need to
     61     // adopt the CloudKit state locally.
     62     private static let inboxEstablishedPrefix = "friend.inboxEstablished."
     63     private static let outboxAcceptedPrefix = "friend.outboxAccepted."
     64 
     65     static func inboxEstablished(pairKey: String) -> Bool {
     66         UserDefaults.standard.bool(forKey: inboxEstablishedPrefix + pairKey)
     67     }
     68 
     69     static func markInboxEstablished(pairKey: String) {
     70         UserDefaults.standard.set(true, forKey: inboxEstablishedPrefix + pairKey)
     71     }
     72 
     73     static func outboxAccepted(pairKey: String) -> Bool {
     74         UserDefaults.standard.bool(forKey: outboxAcceptedPrefix + pairKey)
     75     }
     76 
     77     static func markOutboxAccepted(pairKey: String) {
     78         UserDefaults.standard.set(true, forKey: outboxAcceptedPrefix + pairKey)
     79     }
     80 
     81     private static let bootstrapHealAtPrefix = "friend.bootstrapHealAt."
     82 
     83     /// Minimum spacing between bootstrap re-announcements for one pair. Each
     84     /// re-announcement is a fresh broadcast Ping record that is never
     85     /// consumed, so a friend whose devices stay dormant must not accrue one
     86     /// per launch.
     87     static let bootstrapHealCooldown: TimeInterval = 24 * 60 * 60
     88 
     89     static func canAttemptBootstrapHeal(pairKey: String, now: Date = Date()) -> Bool {
     90         guard let last = UserDefaults.standard.object(
     91             forKey: bootstrapHealAtPrefix + pairKey
     92         ) as? Date else { return true }
     93         return now.timeIntervalSince(last) >= bootstrapHealCooldown
     94     }
     95 
     96     static func markBootstrapHealAttempted(pairKey: String, now: Date = Date()) {
     97         UserDefaults.standard.set(now, forKey: bootstrapHealAtPrefix + pairKey)
     98     }
     99 
    100     /// Owner election: the user whose record name sorts first creates and
    101     /// owns the zone; the other accepts the share. Deterministic on both
    102     /// devices. Equal IDs (same user) can never be friends — returns false.
    103     static func isOwner(localAuthorID: String, remoteAuthorID: String) -> Bool {
    104         localAuthorID != remoteAuthorID && localAuthorID < remoteAuthorID
    105     }
    106 
    107     /// Whether `localAuthorID` is the intended acceptor for a game-zone
    108     /// friendship bootstrap. `.friend` Pings are broadcast to every game
    109     /// participant, so a third collaborator must ignore a pairwise bootstrap
    110     /// meant for someone else instead of attempting to accept the CKShare.
    111     static func canAcceptBootstrap(_ payload: BootstrapPayload, localAuthorID: String?) -> Bool {
    112         guard let localAuthorID, !localAuthorID.isEmpty else { return false }
    113         guard localAuthorID != payload.ownerAuthorID else { return false }
    114         return pairKey(localAuthorID, payload.ownerAuthorID) == payload.pairKey
    115     }
    116 
    117     /// Payload carried in a `.friend` Ping (written into the *game* zone) so
    118     /// the non-owner can accept the friend-zone share without an out-of-band
    119     /// link.
    120     struct BootstrapPayload: Codable, Equatable {
    121         let friendShareURL: String
    122         let pairKey: String
    123         let ownerAuthorID: String
    124 
    125         func encodedString() -> String? {
    126             guard let data = try? JSONEncoder().encode(self) else { return nil }
    127             return String(data: data, encoding: .utf8)
    128         }
    129 
    130         static func decode(_ raw: String?) -> BootstrapPayload? {
    131             guard let raw, let data = raw.data(using: .utf8) else { return nil }
    132             return try? JSONDecoder().decode(BootstrapPayload.self, from: data)
    133         }
    134     }
    135 
    136     /// Payload carried in an `.invite` Ping (written into the *friend* zone)
    137     /// so the recipient can accept the game's `CKShare` from the "Invited"
    138     /// section without an out-of-band link.
    139     struct InvitePayload: Codable, Equatable {
    140         let gameShareURL: String
    141         /// The game's grid silhouette, as a `GridSilhouette`-encoded segment,
    142         /// so the recipient's "Invited" row can preview the puzzle's shape
    143         /// without a CloudKit round-trip — the internal-invite counterpart to
    144         /// the silhouette segment carried in share links. `nil` for non-square
    145         /// grids (which get no preview) and for invites from older senders.
    146         let gridSilhouette: String?
    147         /// The puzzle's full XD source. The recipient already syncs the friend
    148         /// zone, so this arrives with the Ping and lets the accept path build a
    149         /// playable game immediately — the shared-zone fetch then only updates
    150         /// it. Recipients discard this fast-accept source when it exceeds
    151         /// `XD.maxSourceBytes`, falling back to the canonical shared-zone
    152         /// fetch. `nil` for invites from older senders, which fall back to the
    153         /// fetch.
    154         let puzzleSource: String?
    155         /// The owner's shared game notification credential. Direct invites can
    156         /// build a playable game before the shared-zone Game record has synced;
    157         /// carrying the credential here lets the joiner register under the same
    158         /// worker namespace immediately instead of minting a temporary one.
    159         let notification: String?
    160 
    161         // An explicit init keeps the optionals out of the inline default
    162         // (which would exclude them from Codable) while letting existing call
    163         // sites omit them; a missing JSON key decodes to `nil`.
    164         init(
    165             gameShareURL: String,
    166             gridSilhouette: String? = nil,
    167             puzzleSource: String? = nil,
    168             notification: String? = nil
    169         ) {
    170             self.gameShareURL = gameShareURL
    171             self.gridSilhouette = gridSilhouette
    172             self.puzzleSource = puzzleSource
    173             self.notification = notification
    174         }
    175 
    176         func encodedString() -> String? {
    177             guard let data = try? JSONEncoder().encode(self) else { return nil }
    178             return String(data: data, encoding: .utf8)
    179         }
    180 
    181         static func decode(_ raw: String?) -> InvitePayload? {
    182             guard let raw, let data = raw.data(using: .utf8) else { return nil }
    183             return try? JSONDecoder().decode(InvitePayload.self, from: data)
    184         }
    185     }
    186 }