crossmate

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

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 }