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 }