Presence.swift (9774B)
1 import CloudKit 2 import Foundation 3 4 /// The single rule for "is a peer present." A peer is present iff their 5 /// active-session lease (`Player.presenceUntil`) is still in the future, or lapsed no 6 /// more than `presenceGrace` ago — the cursor (`PlayerRoster`), the engagement 7 /// icon, engagement teardown, and the selection-publisher send gate all derive 8 /// presence from this. 9 /// 10 /// The grace exists because `presenceUntil` doubles as the account read horizon and is 11 /// legitimately collapsed to a current-time value whenever a device 12 /// backgrounds or leaves (it must close the lease synchronously, without a 13 /// background assertion it can't rely on). A co-solver bouncing between apps — 14 /// expected to be common — therefore writes a current-time `presenceUntil` and returns 15 /// seconds later. Without a grace the partner would see them blink out and back 16 /// on every such hop; the grace treats a brief absence as continued presence, 17 /// at the cost of a departed peer lingering for up to `presenceGrace`. 18 enum PeerPresence { 19 /// How long a lapsed `presenceUntil` still counts as present. Sized to cover a 20 /// realistic app-switch — glancing at and replying to a notification — with 21 /// margin, while clearing a genuine departure within about a minute. The 22 /// costs are asymmetric: too short reintroduces the blink, too long only 23 /// lingers a stale cursor, so this rounds up. 24 static let presenceGrace: TimeInterval = 60 25 26 /// The cutoff a `presenceUntil` must exceed to count as present. Exposed for 27 /// callers that filter in a query rather than per-record (Core Data 28 /// predicates), so they stay in lockstep with `isPresent`. 29 static func presenceCutoff(asOf now: Date = Date()) -> Date { 30 now.addingTimeInterval(-presenceGrace) 31 } 32 33 static func isPresent(presenceUntil: Date?, asOf now: Date = Date()) -> Bool { 34 guard let presenceUntil else { return false } 35 return presenceUntil > presenceCutoff(asOf: now) 36 } 37 } 38 39 /// What a Ping record represents. Stored as a string in the CKRecord's 40 /// `kind` field. Pings now cover durable bootstrap/side-channel events that 41 /// do not need live APN timing. User-facing play events ride on the push 42 /// worker, and simultaneous co-solving rides on engagement state. 43 enum PingKind: String, Codable, Sendable { 44 /// Legacy collaborator-joined notification. New clients no longer write 45 /// or alert on this kind; it remains parseable for old records. 46 case join 47 /// Friendship bootstrap. Written into a shared *game* zone; carries the 48 /// friend-zone share URL in `payload`. System-only — never user-facing. 49 case friend 50 /// Re-invite to a game. Written into a *friend* zone; carries the game's 51 /// share URL in `payload`. Surfaces in the "Invited" section. 52 case invite 53 /// Invitee-declined notice. Written into a *friend* zone addressed back to 54 /// the inviter; carries no payload. The inviter's device frees the 55 /// declined seat on the game's `CKShare` and surfaces a banner. 56 case decline 57 /// Legacy engagement room bootstrap. Live rooms now rendezvous through 58 /// Game-record engagement credentials; this remains parseable for cleanup. 59 case hail 60 /// Durable completion handshake. A participant writes this into the live 61 /// game zone only after their complete private Chronicle has saved. The owner 62 /// retains these records until it retires the entire zone. 63 case chronicled 64 } 65 66 /// The CloudKit database a fetched record or zone belongs to, naming the raw 67 /// `Int16` convention used throughout the app (Core Data's 68 /// `GameEntity.databaseScope`, sync bookkeeping, push payloads): `0` is the 69 /// private database — zones this user owns — and `1` is the shared database — 70 /// zones this user joined. Plumbing between components traffics in this enum; 71 /// `rawValue` converts at the Core Data storage boundary (entity attributes 72 /// and predicates stay `Int16`). 73 enum DatabaseScope: Int16, Codable, Sendable { 74 case `private` = 0 75 case shared = 1 76 77 init(isPrivate: Bool) { 78 self = isPrivate ? .private : .shared 79 } 80 81 /// Reads an entity's stored `databaseScope`. Only `0`/`1` are ever 82 /// written; anything else falls back to `.private`, matching the historic 83 /// `scope == 1 ? shared : private` reading of the raw column. 84 init(entityValue: Int16) { 85 self = DatabaseScope(rawValue: entityValue) ?? .private 86 } 87 } 88 89 struct Ping: Sendable { 90 let recordName: String 91 let gameID: UUID 92 let authorID: String 93 let deviceID: String 94 let playerName: String 95 let puzzleTitle: String 96 let kind: PingKind 97 /// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`; 98 /// `.invite`: `{gameShareURL}`; legacy `.hail` carried engagement room 99 /// bootstrap; nil for legacy `.join`. 100 let payload: String? 101 /// Recipient authorID for a directed ping. nil means broadcast. 102 let addressee: String? 103 /// The zone the record was fetched from — intrinsic CloudKit metadata, not 104 /// a writable payload field. For a friend-zone ping this is 105 /// `friend-<pairKey>`, which authenticates the *writer* of the zone 106 /// independently of the self-asserted `authorID`. `.decline` handling gates 107 /// on this so a forged ping in one friend's inbox can't impersonate another. 108 let sourceZoneName: String 109 /// Which database the record was fetched from — like `sourceZoneName`, 110 /// intrinsic fetch context, not a writable payload field. A legitimate 111 /// invite or decline is written by the friend into *this user's* inbox — a 112 /// zone this user owns, i.e. `.private` — so the friend-zone authenticity 113 /// gate requires `.private`, which no zone a forger owns can satisfy. 114 let sourceDatabaseScope: DatabaseScope? 115 116 /// `sourceZoneName` defaults to empty and `sourceDatabaseScope` to nil so 117 /// tests can construct pings without them; an empty zone name can never 118 /// match a real `friend-<pairKey>` zone and an unknown scope is never 119 /// `.private`, so the friend-zone gate stays fail-closed. Production 120 /// always parses both from the fetch via `parseRecord`. 121 init( 122 recordName: String, 123 gameID: UUID, 124 authorID: String, 125 deviceID: String, 126 playerName: String, 127 puzzleTitle: String, 128 kind: PingKind, 129 payload: String?, 130 addressee: String?, 131 sourceZoneName: String = "", 132 sourceDatabaseScope: DatabaseScope? = nil 133 ) { 134 self.recordName = recordName 135 self.gameID = gameID 136 self.authorID = authorID 137 self.deviceID = deviceID 138 self.playerName = playerName 139 self.puzzleTitle = puzzleTitle 140 self.kind = kind 141 self.payload = payload 142 self.addressee = addressee 143 self.sourceZoneName = sourceZoneName 144 self.sourceDatabaseScope = sourceDatabaseScope 145 } 146 147 static func parseRecord(_ record: CKRecord, fetchedFrom scope: DatabaseScope?) -> Ping? { 148 let name = record.recordID.recordName 149 // Identity lives in the record name (like Moves/Player/Journal). The 150 // zone-name branch is a fallback for a record whose name isn't a ping 151 // name; it can only recover the gameID, not the author/device. 152 let gameID: UUID? 153 let authorID: String? 154 let deviceID: String 155 if let (parsedGameID, parsedAuthor, parsedDevice) = 156 RecordSerializer.parsePingRecordName(name) { 157 gameID = parsedGameID 158 authorID = parsedAuthor 159 deviceID = parsedDevice 160 } else if record.recordID.zoneID.zoneName.hasPrefix("game-") { 161 gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count))) 162 authorID = nil 163 deviceID = "" 164 } else { 165 gameID = nil 166 authorID = nil 167 deviceID = "" 168 } 169 guard let gameID, 170 let authorID, 171 let kindRaw = record["kind"] as? String, 172 let kind = PingKind(rawValue: kindRaw) 173 else { return nil } 174 return Ping( 175 recordName: name, 176 gameID: gameID, 177 authorID: authorID, 178 deviceID: deviceID, 179 playerName: (record["playerName"] as? String) ?? "", 180 puzzleTitle: (record["puzzleTitle"] as? String) ?? "", 181 kind: kind, 182 payload: record["payload"] as? String, 183 addressee: record["addressee"] as? String, 184 sourceZoneName: record.recordID.zoneID.zoneName, 185 sourceDatabaseScope: scope 186 ) 187 } 188 } 189 190 struct Session: Sendable { 191 let recordName: String 192 let gameID: UUID 193 let authorID: String 194 let playerName: String 195 let puzzleTitle: String 196 let updatedAt: Date 197 198 static func parseRecord(_ record: CKRecord, puzzleTitle: String) -> Session? { 199 guard let (gameID, authorIDFromName) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) 200 else { return nil } 201 // A cleared selection is the player leaving the puzzle, not starting 202 // or actively navigating it. 203 guard RecordSerializer.parsePlayerSelection(from: record) != nil else { return nil } 204 let authorID = (record["authorID"] as? String) ?? authorIDFromName 205 let updatedAt = (record["updatedAt"] as? Date) 206 ?? record.modificationDate 207 ?? Date() 208 return Session( 209 recordName: record.recordID.recordName, 210 gameID: gameID, 211 authorID: authorID, 212 playerName: (record["name"] as? String) ?? "", 213 puzzleTitle: puzzleTitle, 214 updatedAt: updatedAt 215 ) 216 } 217 }