crossmate

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

commit 1524e3fed29a1218aaf5af13b74f71f8d97066a3
parent cbf3e003e957cb298273fd49604c40ab3f9841ca
Author: Michael Camilleri <[email protected]>
Date:   Wed, 27 May 2026 09:51:12 +0900

Drain retired play-event pings and rename play/pause push kinds

This commit adds a one-shot per-device migration that sweeps .check,
.reveal, .resign, .win and the old session-start .join Ping records
out of every game zone the device can reach. Those kinds were retired when the
push worker took over user-facing event notifications, but records written by
pre-cutover builds still sit in shared game zones and will replay as obsolete
announcements on the next CKSyncEngine fetch.

It also renames the session-start/session-end push kinds the iOS app ships to
the Worker from join/leave to play/pause. The receiver doesn't branch
on these strings (the Worker forwards kind opaquely into the APN payload, and
iOS notification center renders the alert without consulting it), so this is
purely a clarity change: PingKind.join continues to mean the invite-accept
bootstrap ping, and the user-facing 'started a session' event is now called
play, which is what it actually describes.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 18+++++++++++-------
MCrossmate/Services/LocalSessionTracker.swift | 4++--
MCrossmate/Sync/SyncEngine.swift | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
MShared/NotificationState.swift | 16++++++++++++++++
4 files changed, 81 insertions(+), 9 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -597,10 +597,12 @@ final class AppServices { } /// Sender-side session-start push. Replaces the receiver-side - /// `SessionMonitor.presentBegins(...)` path: the leaving/joining device - /// owns the notification timing, so peers get "Alice is solving X" the - /// instant Alice opens the puzzle. Also resets the local edit tracker so - /// the matching leave push counts only this segment. + /// `SessionMonitor.presentBegins(...)` path: the opening device owns the + /// notification timing, so peers get "Alice is solving X" the instant + /// Alice opens the puzzle. Also resets the local edit tracker so the + /// matching pause push counts only this segment. Kind is `play` (rather + /// than `join`) to avoid collision with the `PingKind.join` invite-accept + /// ping — the user-facing event is "started playing", not "joined". func publishSessionStartPush(gameID: UUID) async { localSessionTracker.begin(gameID: gameID) guard let pushClient, @@ -611,7 +613,7 @@ final class AppServices { let playerName = preferences.name.isEmpty ? "A player" : preferences.name let puzzleSuffix = lookup.title.isEmpty ? "the puzzle" : "the puzzle '\(lookup.title)'" await pushClient.publish( - kind: "join", + kind: "play", gameID: gameID, addressees: lookup.recipients, title: "Crossmate", @@ -623,7 +625,9 @@ final class AppServices { /// the same summary text the receiver-side quiescence path used to build /// (`SessionMonitor.bodyText`), and ships it. A session with no edits /// (opened and closed without typing, or already drained by a prior - /// background-publish) skips the push entirely. + /// background-publish) skips the push entirely. Kind is `pause` — the + /// user is stepping away from a puzzle they may return to, not + /// permanently leaving the game. func publishSessionEndPush(gameID: UUID) async { let counts = localSessionTracker.consume(gameID: gameID) guard counts.added > 0 || counts.cleared > 0 else { return } @@ -643,7 +647,7 @@ final class AppServices { } let action = parts.joined(separator: " and ") await pushClient.publish( - kind: "leave", + kind: "pause", gameID: gameID, addressees: lookup.recipients, title: "Crossmate", diff --git a/Crossmate/Services/LocalSessionTracker.swift b/Crossmate/Services/LocalSessionTracker.swift @@ -1,9 +1,9 @@ import Foundation /// Per-session running tally of the local player's adds and clears, used to -/// build the body text of the leave APN. Reset on every `begin(gameID:)` so a +/// build the body text of the pause APN. Reset on every `begin(gameID:)` so a /// background/foreground cycle on the same puzzle starts each segment from -/// zero — the prior segment's counts already shipped in its own leave push. +/// zero — the prior segment's counts already shipped in its own pause push. @MainActor final class LocalSessionTracker { private(set) var activeGameID: UUID? diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -237,6 +237,7 @@ actor SyncEngine { Task { await purgeLegacyInvitePings_v1() } Task { await purgeStaleHailPings_v1() } Task { await purgeDebugPreviewFriends_v1() } + Task { await purgeLegacyPlayPings_v1() } } private func ensureDatabaseSubscriptions() async { @@ -626,6 +627,57 @@ actor SyncEngine { return deleted } + /// One-shot cleanup of Ping records whose kinds were retired when the + /// push worker took over user-facing event notifications: + /// `.check`, `.reveal`, `.resign`, `.win`, and the old session-start + /// `.join`. Those kinds are gone from `PingKind`, but records written + /// by pre-cutover builds linger in shared game zones and will replay + /// as obsolete announcements after a zone fetch. Every device drains + /// the game zones it can reach exactly once. + func purgeLegacyPlayPings_v1() async { + guard NotificationState.legacyPlayPingPurgeNeeded() else { return } + do { + let privateDeleted = try await purgeLegacyPlayPings( + in: gameZoneIDs(forScope: 0), + database: container.privateCloudDatabase + ) + let sharedDeleted = try await purgeLegacyPlayPings( + in: gameZoneIDs(forScope: 1), + database: container.sharedCloudDatabase + ) + NotificationState.markLegacyPlayPingPurged() + let total = privateDeleted + sharedDeleted + if total > 0 { + await trace("legacy play-ping purge: deleted \(total) record(s)") + } + } catch { + await trace("legacy play-ping purge failed: \(describe(error))") + } + } + + private func purgeLegacyPlayPings( + in zoneIDs: [CKRecordZone.ID], + database: CKDatabase + ) async throws -> Int { + var deleted = 0 + let predicate = NSPredicate( + format: "kind IN %@", + ["check", "reveal", "resign", "win", "join"] + ) + for zoneID in zoneIDs { + let records = try await queryRecords( + type: "Ping", + database: database, + zoneID: zoneID, + predicate: predicate, + desiredKeys: [] + ) + try await deleteRecords(withIDs: records.map(\.recordID), in: database) + deleted += records.count + } + return deleted + } + private func gameZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] { let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -193,6 +193,7 @@ enum NotificationState { private static let legacyInvitePurgeKey = "migration.legacyInvitePurge.v1" private static let staleHailPurgeKey = "migration.staleHailPurge.v1" private static let debugPreviewFriendPurgeKey = "migration.debugPreviewFriendPurge.v1" + private static let legacyPlayPingPurgeKey = "migration.legacyPlayPingPurge.v1" /// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings /// has not yet run successfully on this device. The flag is per-device @@ -242,4 +243,19 @@ enum NotificationState { static func markDebugPreviewFriendPurged() { defaults?.set(true, forKey: debugPreviewFriendPurgeKey) } + + /// True if the one-shot cleanup of retired play-event Ping kinds + /// (`.check`, `.reveal`, `.resign`, `.win`, and the old session-start + /// `.join`) has not yet run successfully on this device. Those kinds + /// were retired when the push worker took over user-facing event + /// notifications; any records still in game zones are dead weight that + /// can resurrect obsolete announcements on a zone replay. + static func legacyPlayPingPurgeNeeded() -> Bool { + defaults?.bool(forKey: legacyPlayPingPurgeKey) == false + } + + /// Records that the legacy play-ping purge completed successfully. + static func markLegacyPlayPingPurged() { + defaults?.set(true, forKey: legacyPlayPingPurgeKey) + } }