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 }