Presence.swift (6225B)
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.readAt`) 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 `readAt` 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 `readAt` 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 `readAt` 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 `readAt` 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(readAt: Date?, asOf now: Date = Date()) -> Bool { 34 guard let readAt else { return false } 35 return readAt > 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, 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 } 61 62 struct Ping: Sendable { 63 let recordName: String 64 let gameID: UUID 65 let authorID: String 66 let deviceID: String 67 let playerName: String 68 let puzzleTitle: String 69 let kind: PingKind 70 /// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`; 71 /// `.invite`: `{gameShareURL}`; legacy `.hail` carried engagement room 72 /// bootstrap; nil for legacy `.join`. 73 let payload: String? 74 /// Recipient authorID for a directed ping. nil means broadcast. 75 let addressee: String? 76 77 static func parseRecord(_ record: CKRecord) -> Ping? { 78 let name = record.recordID.recordName 79 let gameID: UUID? 80 if name.hasPrefix("ping-") { 81 let rest = name.dropFirst("ping-".count) 82 gameID = UUID(uuidString: String(rest.prefix(36))) 83 } else if record.recordID.zoneID.zoneName.hasPrefix("game-") { 84 gameID = UUID(uuidString: String(record.recordID.zoneID.zoneName.dropFirst("game-".count))) 85 } else { 86 gameID = nil 87 } 88 guard let gameID, 89 let authorID = record["authorID"] as? String, 90 let kindRaw = record["kind"] as? String, 91 let kind = PingKind(rawValue: kindRaw) 92 else { return nil } 93 // Legacy records written before the schema added `deviceID` won't have 94 // the field. Parse-tolerant: empty string can never equal a real 95 // localDeviceID, so the self-send filter stays safe. 96 let deviceID = (record["deviceID"] as? String) ?? "" 97 return Ping( 98 recordName: name, 99 gameID: gameID, 100 authorID: authorID, 101 deviceID: deviceID, 102 playerName: (record["playerName"] as? String) ?? "", 103 puzzleTitle: (record["puzzleTitle"] as? String) ?? "", 104 kind: kind, 105 payload: record["payload"] as? String, 106 addressee: record["addressee"] as? String 107 ) 108 } 109 } 110 111 struct Session: Sendable { 112 let recordName: String 113 let gameID: UUID 114 let authorID: String 115 let playerName: String 116 let puzzleTitle: String 117 let updatedAt: Date 118 119 static func parseRecord(_ record: CKRecord, puzzleTitle: String) -> Session? { 120 guard let (gameID, authorIDFromName) = RecordSerializer.parsePlayerRecordName(record.recordID.recordName) 121 else { return nil } 122 // A cleared selection is the player leaving the puzzle, not starting 123 // or actively navigating it. 124 guard RecordSerializer.parsePlayerSelection(from: record) != nil else { return nil } 125 let authorID = (record["authorID"] as? String) ?? authorIDFromName 126 let updatedAt = (record["updatedAt"] as? Date) 127 ?? record.modificationDate 128 ?? Date() 129 return Session( 130 recordName: record.recordID.recordName, 131 gameID: gameID, 132 authorID: authorID, 133 playerName: (record["name"] as? String) ?? "", 134 puzzleTitle: puzzleTitle, 135 updatedAt: updatedAt 136 ) 137 } 138 }