crossmate

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

commit 037807d3be7fdfd8f063053745eaa868a5227fe1
parent 36b2e5296a3dc8cfeb3c6ab201d48f5b1d0dcced
Author: Michael Camilleri <[email protected]>
Date:   Mon, 18 May 2026 08:03:41 +0900

Fix ping bugs

Production check/reveal pings were BadSyntax-failing on a 'scope' field never
deployed to the production schema, while the generic 'payload' slot (already in
production) does the same job — 'scope' was just an older, narrower field that
predates it and is set for no other kind. pingRecord now folds a check/reveal PingScope into payload as {"scope":"…"}
via a new PingScopePayload codec; the top-level Ping.scope field is no longer
written or read, and is dropped from ping desiredKeys.

Additionally, Phase 2 scene-phase wiring fired an .opened/.closed ping on every
transition including the transient .inactive, while the poll loop kept
refreshing the lease for a backgrounded puzzle whose view hadn't disappeared,
so a single lock/unlock thrashed the lease open/closed several times over. The
scene-phase handler now acts only on settled transitions (.background → closed,
.active → opened, .inactive → no-op), and the poll-loop lease refresh is gated
on NotificationState.activePuzzleID() == gameID, so a backgrounded puzzle stops
claiming the lease.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 32++++++++++++++++++++++----------
MCrossmate/Sync/RecordSerializer.swift | 17+++++++++++------
MCrossmate/Sync/SyncEngine.swift | 47++++++++++++++++++++++++++++++++++++++++-------
MTests/Unit/OpenedLeaseTests.swift | 33+++++++++++++++++++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 23+++++++++++++++++++++++
5 files changed, 129 insertions(+), 23 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -424,16 +424,21 @@ private struct PuzzleDisplayView: View { } .onChange(of: scenePhase) { _, newPhase in updateActiveNotificationPuzzleID(for: newPhase) - // Releasing/refreshing the cross-device lease mirrors the local - // active state: backgrounding ends it promptly so siblings stop - // suppressing; returning re-opens it without waiting for the poll. + // Only act on settled transitions. `.inactive` is transient (lock + // animation, app switcher, Control Center, banners) — sending a + // ping on it thrashed the lease open/closed many times per + // lock/unlock. `.background` ends it promptly so siblings stop + // suppressing; `.active` re-opens it without waiting for the poll. let id = gameID - Task { - if newPhase == .active { - await services.refreshActiveLease(for: id) - } else { - await services.closeActiveLease(for: id) - } + switch newPhase { + case .active: + Task { await services.refreshActiveLease(for: id) } + case .background: + Task { await services.closeActiveLease(for: id) } + case .inactive: + break + @unknown default: + break } } .onDisappear { @@ -556,7 +561,14 @@ private struct PuzzleDisplayView: View { } guard !Task.isCancelled else { break } guard let scope = syncedScope else { break } - if Date().timeIntervalSince(lastLeaseSentAt) + // Only refresh the lease while this puzzle is genuinely the + // foreground-active one. The poll loop isn't cancelled when the + // app backgrounds (the view stays alive), so without this gate a + // backgrounded puzzle kept re-broadcasting "I'm viewing" every + // interval. `.background` already sent `.closed`; resume on + // return to `.active`. + if NotificationState.activePuzzleID() == gameID, + Date().timeIntervalSince(lastLeaseSentAt) >= NotificationState.leaseRefreshInterval { await services.refreshActiveLease(for: gameID) lastLeaseSentAt = Date() diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -176,7 +176,10 @@ enum RecordSerializer { /// between a single user's own devices, where authorID is identical. /// - `playerName` and `puzzleTitle` let receivers render the alert body. /// - `kind` distinguishes join/win/check/reveal/opened events. - /// - `scope` is set only for check/reveal kinds. + /// - `scope` (check/reveal granularity) is folded into `payload` as + /// `{"scope":"…"}` — the legacy top-level `scope` field is no longer + /// written (see PingScope). `scope`/`payload` are mutually exclusive + /// across kinds, so there is never anything to merge. static func pingRecord( gameID: UUID, authorID: String, @@ -202,11 +205,13 @@ enum RecordSerializer { record["playerName"] = playerName as CKRecordValue record["puzzleTitle"] = puzzleTitle as CKRecordValue record["kind"] = kind.rawValue as CKRecordValue - if let scope { - record["scope"] = scope.rawValue as CKRecordValue - } - if let payload { - record["payload"] = payload as CKRecordValue + // `scope` and `payload` never co-occur across kinds; fold a legacy + // `scope` into the generic payload rather than writing the now-frozen + // top-level field. + let outPayload = payload + ?? scope.flatMap { PingScopePayload(scope: $0.rawValue).encoded() } + if let outPayload { + record["payload"] = outPayload as CKRecordValue } return record } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -46,8 +46,19 @@ enum PingKind: String, Sendable { case invite } -/// Granularity of a check/reveal action. Stored as a string in the CKRecord's -/// `scope` field; nil for kinds where it doesn't apply. +/// Granularity of a check/reveal action; nil for kinds where it doesn't apply. +/// +/// LEGACY SCHEMA NOTE: this used to be its own `Ping.scope` CKRecord field, +/// predating the generic `payload` slot. It now rides in `payload` as +/// `{"scope":"…"}` like every other kind-specific datum. The old `Ping.scope` +/// CKRecord field is DEAD: no code writes or reads it. It still exists in the +/// deployed production CloudKit schema only because CloudKit cannot delete +/// fields from a deployed type. +/// +/// ┌─────────────────────────────────────────────────────────────────┐ +/// │ When the CloudKit container/schema is next rebuilt clean, do NOT │ +/// │ recreate the `Ping.scope` field. It is intentionally gone. │ +/// └─────────────────────────────────────────────────────────────────┘ enum PingScope: String, Sendable { case square case word @@ -64,7 +75,8 @@ struct Ping: Sendable { let kind: PingKind let scope: PingScope? /// Kind-specific JSON. `.friend`: `{friendShareURL,pairKey,ownerAuthorID}`; - /// `.invite`: `{gameShareURL}`. nil for join/win/check/reveal/opened. + /// `.invite`: `{gameShareURL}`; `.check`/`.reveal`: `{scope}` (see + /// PingScope); `.opened`/`.closed`: the OpenedLease. nil for join/win. let payload: String? } @@ -97,6 +109,23 @@ struct OpenedLease: Codable, Sendable { } } +/// `payload` JSON for `.check`/`.reveal` pings — the check/reveal granularity +/// that used to live in the legacy `Ping.scope` field (see PingScope). The +/// `scope` key is required, so decoding a different kind's payload (lease, +/// friend, invite) yields nil rather than a false match. +struct PingScopePayload: Codable, Sendable { + let scope: String + + func encoded() -> String? { + (try? JSONEncoder().encode(self)).flatMap { String(data: $0, encoding: .utf8) } + } + + static func decode(_ string: String?) -> PingScopePayload? { + guard let data = string?.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(PingScopePayload.self, from: data) + } +} + /// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for /// the private database (owned games and shares) and one for the shared /// database (joined games). Zone creation, subscription setup, change-token @@ -907,7 +936,7 @@ actor SyncEngine { database: database, zoneID: zoneID, since: since, - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope", "payload"] + desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"] ) return PerZonePings(records: records, orphanedZone: nil) } catch { @@ -1028,7 +1057,7 @@ actor SyncEngine { database: database, zoneID: zone.zoneID, since: since, - desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "scope", "payload"] + desiredKeys: ["authorID", "deviceID", "playerName", "puzzleTitle", "kind", "payload"] ) async let playerRecords = self.queryLiveRecords( type: "Player", @@ -2499,7 +2528,11 @@ actor SyncEngine { let kindRaw = record["kind"] as? String, let kind = PingKind(rawValue: kindRaw) else { return nil } - let scope: PingScope? = (record["scope"] as? String).flatMap(PingScope.init(rawValue:)) + // Scope rides in `payload` as `{"scope":"…"}`. The old top-level + // `scope` field is dead — nothing reads or writes it (see PingScope). + let payloadString = record["payload"] as? String + let scope: PingScope? = + PingScopePayload.decode(payloadString).flatMap { PingScope(rawValue: $0.scope) } // Legacy records written before the schema added `deviceID` won't have // the field. Parse-tolerant: empty string can never equal a real // localDeviceID, so the self-send filter stays safe. @@ -2513,7 +2546,7 @@ actor SyncEngine { puzzleTitle: (record["puzzleTitle"] as? String) ?? "", kind: kind, scope: scope, - payload: record["payload"] as? String + payload: payloadString ) } diff --git a/Tests/Unit/OpenedLeaseTests.swift b/Tests/Unit/OpenedLeaseTests.swift @@ -30,3 +30,36 @@ struct OpenedLeaseTests { #expect(OpenedLease.decode("{\"leaseMs\":1}") == nil) } } + +@Suite("Ping scope payload") +struct PingScopePayloadTests { + @Test("Round-trips through its JSON payload") + func roundTrip() throws { + for scope in [PingScope.square, .word, .puzzle] { + let encoded = try #require( + PingScopePayload(scope: scope.rawValue).encoded() + ) + let decoded = try #require(PingScopePayload.decode(encoded)) + #expect(PingScope(rawValue: decoded.scope) == scope) + } + } + + @Test("decode is nil for missing or malformed payload") + func decodeNilOnGarbage() { + #expect(PingScopePayload.decode(nil) == nil) + #expect(PingScopePayload.decode("") == nil) + #expect(PingScopePayload.decode("not json") == nil) + } + + @Test("Other kinds' payloads don't false-match as a scope payload") + func crossKindIsolation() { + // A lease payload has no `scope` key. + let lease = OpenedLease(leaseMs: 300_000, sentAtMs: 1).encoded() + #expect(PingScopePayload.decode(lease) == nil) + // …and a scope payload isn't a valid lease. + let scope = PingScopePayload(scope: "word").encoded() + #expect(OpenedLease.decode(scope) == nil) + // An invite-shaped payload also has no `scope` key. + #expect(PingScopePayload.decode(#"{"gameShareURL":"https://x"}"#) == nil) + } +} diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -134,6 +134,29 @@ struct RecordSerializerTests { #expect(withoutPayload["payload"] == nil) } + @Test("check/reveal scope is folded into payload, not the legacy field") + func pingRecordFoldsScopeIntoPayload() throws { + let zone = CKRecordZone.ID(zoneName: "z", ownerName: CKCurrentUserDefaultName) + let record = RecordSerializer.pingRecord( + gameID: UUID(), + authorID: "alice", + deviceID: "deviceA", + playerName: "Alice", + puzzleTitle: "Puzzle", + eventTimestampMs: 1700000000000, + kind: .check, + scope: .word, + zone: zone + ) + // The frozen top-level field is no longer written. + #expect(record["scope"] == nil) + let decoded = try #require( + PingScopePayload.decode(record["payload"] as? String) + ) + #expect(decoded.scope == "word") + #expect(PingScope(rawValue: decoded.scope) == .word) + } + @Test("accountZoneID is named 'account' in the current user's private DB") func accountZoneIDShape() { let zone = RecordSerializer.accountZoneID