crossmate

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

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 }