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:
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)
+ }
}