crossmate

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

commit 738a9a685f9ad0ba58b3eb8693d213c7832c854f
parent b99e52ee430b0d25dab0ac369b1d9c9d488f14d3
Author: Michael Camilleri <[email protected]>
Date:   Wed, 27 May 2026 14:13:56 +0900

Suppress play/pause pushes for finished or revoked games

publishSessionStartPush and publishSessionEndPush fire whenever the puzzle view
becomes active, with no regard for whether the game is already finished or has
had its shared zone torn down server-side. Opening a completed puzzle to review
the grid was fanning out 'X is solving the puzzle' to peers; a revoked shared
game would do the same right up until the orphan handler caught up.

This commit folds completedAt and isAccessRevoked into the per-push lookup —
now a PushPlan struct rather than a growing tuple — and have the play and pause
helpers return early when either is set. The completion helper keeps emitting
unconditionally; its whole purpose is to fire on completion.

This commit also stops enqueuePlayer from re-saving Player records into a zone
the orphan handler has already marked revoked. The open-puzzle UI keeps calling
in for read-cursor / selection / name-open writes while the user lingers on the
revoked grid, and each one was kicking off another round of the same
ZoneNotFound teardown the prior round just resolved.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MCrossmate/Sync/CloudZones.swift | 4+++-
MCrossmate/Sync/SyncEngine.swift | 14++++++++++++++
3 files changed, 77 insertions(+), 20 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -620,17 +620,29 @@ final class AppServices { syncMonitor.note("push(play): skipped (no authorID)") return } - let lookup = recipientsAndTitle(forGameID: gameID, excluding: localAuthorID) - guard !lookup.recipients.isEmpty else { + let plan = pushPlan(forGameID: gameID, excluding: localAuthorID) + guard !plan.recipients.isEmpty else { syncMonitor.note("push(play): skipped (no recipients)") return } + // Opening a finished puzzle (review, share view) isn't a play + // session — peers don't need a "solving the puzzle" alert. Same for + // a shared game we no longer have access to: the owner already + // unshared or deleted it, and the worker would just bounce the push. + guard plan.completedAt == nil else { + syncMonitor.note("push(play): skipped (game completed)") + return + } + guard !plan.isAccessRevoked else { + syncMonitor.note("push(play): skipped (access revoked)") + return + } let playerName = preferences.name.isEmpty ? "A player" : preferences.name - let puzzleSuffix = lookup.title.isEmpty ? "the puzzle" : "the puzzle '\(lookup.title)'" + let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" await pushClient.publish( kind: "play", gameID: gameID, - addressees: lookup.recipients, + addressees: plan.recipients, title: "Crossmate", body: "\(playerName) is solving \(puzzleSuffix)" ) @@ -689,13 +701,23 @@ final class AppServices { syncMonitor.note("push(pause): skipped (no authorID)") return } - let lookup = recipientsAndTitle(forGameID: gameID, excluding: localAuthorID) - guard !lookup.recipients.isEmpty else { + let plan = pushPlan(forGameID: gameID, excluding: localAuthorID) + guard !plan.recipients.isEmpty else { syncMonitor.note("push(pause): skipped (no recipients)") return } + // Symmetric with `publishSessionStartPush`: a finished or revoked + // game has no live play session, so a pause summary is meaningless. + guard plan.completedAt == nil else { + syncMonitor.note("push(pause): skipped (game completed)") + return + } + guard !plan.isAccessRevoked else { + syncMonitor.note("push(pause): skipped (access revoked)") + return + } let playerName = preferences.name.isEmpty ? "A player" : preferences.name - let puzzleSuffix = lookup.title.isEmpty ? "the puzzle" : "the puzzle '\(lookup.title)'" + let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" var parts: [String] = [] if counts.added > 0 { parts.append("added \(counts.added) \(counts.added == 1 ? "letter" : "letters")") @@ -707,7 +729,7 @@ final class AppServices { await pushClient.publish( kind: "pause", gameID: gameID, - addressees: lookup.recipients, + addressees: plan.recipients, title: "Crossmate", body: "\(playerName) \(action) in \(puzzleSuffix)" ) @@ -723,13 +745,13 @@ final class AppServices { syncMonitor.note("push(\(kindLabel)): skipped (no authorID)") return } - let lookup = recipientsAndTitle(forGameID: gameID, excluding: localAuthorID) - guard !lookup.recipients.isEmpty else { + let plan = pushPlan(forGameID: gameID, excluding: localAuthorID) + guard !plan.recipients.isEmpty else { syncMonitor.note("push(\(kindLabel)): skipped (no recipients)") return } let playerName = preferences.name.isEmpty ? "A player" : preferences.name - let puzzleSuffix = lookup.title.isEmpty ? "the puzzle" : "the puzzle '\(lookup.title)'" + let puzzleSuffix = plan.title.isEmpty ? "the puzzle" : "the puzzle '\(plan.title)'" let kind: String let body: String if resigned { @@ -742,25 +764,39 @@ final class AppServices { await pushClient.publish( kind: kind, gameID: gameID, - addressees: lookup.recipients, + addressees: plan.recipients, title: "Crossmate", body: body ) } - /// One Core Data round-trip to fetch both pieces every push needs: who to - /// notify (other roster authors, excluding the local user and the - /// CKShare placeholder author) and the puzzle's display title. - private func recipientsAndTitle( + /// Everything a sender-side push helper needs to know about a game in + /// one Core Data round-trip: the roster authors to notify, the puzzle's + /// display title, and the gating flags callers consult before emitting. + private struct PushPlan { + let recipients: [String] + let title: String + let completedAt: Date? + let isAccessRevoked: Bool + + static let empty = PushPlan( + recipients: [], + title: "", + completedAt: nil, + isAccessRevoked: false + ) + } + + private func pushPlan( forGameID gameID: UUID, excluding localAuthorID: String - ) -> (recipients: [String], title: String) { + ) -> PushPlan { let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) gReq.fetchLimit = 1 - guard let game = try? ctx.fetch(gReq).first else { return ([], "") } + guard let game = try? ctx.fetch(gReq).first else { return .empty } var authors = Set<String>() let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") pReq.predicate = NSPredicate(format: "game == %@", game) @@ -771,7 +807,12 @@ final class AppServices { authors.remove(localAuthorID) authors.remove(CKCurrentUserDefaultName) authors.remove("") - return (Array(authors), PuzzleNotificationText.title(for: game)) + return PushPlan( + recipients: Array(authors), + title: PuzzleNotificationText.title(for: game), + completedAt: game.completedAt, + isAccessRevoked: game.isAccessRevoked + ) } } diff --git a/Crossmate/Sync/CloudZones.swift b/Crossmate/Sync/CloudZones.swift @@ -6,6 +6,7 @@ extension SyncEngine { struct ZoneInfo { let scope: Int16 let zoneID: CKRecordZone.ID + let isAccessRevoked: Bool } struct ActivityZoneInfo: Sendable { @@ -30,7 +31,8 @@ extension SyncEngine { let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName return ZoneInfo( scope: entity.databaseScope, - zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) + zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), + isAccessRevoked: entity.isAccessRevoked ) } } diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -853,6 +853,20 @@ actor SyncEngine { func enqueuePlayer(gameID: UUID, authorID: String, reason: String) async { let ctx = persistence.container.newBackgroundContext() guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } + // The shared zone has already been confirmed missing server-side + // (see `applyZoneOrphaning`). Re-saving a Player record would just + // fail with `.zoneNotFound` and drag the orphan handler through + // another round of the same teardown work — open-game UI keeps + // calling here for read-cursor / selection / name-open while the + // user lingers on the revoked puzzle. Silently dropping the enqueue + // is the only sensible response. + guard !info.isAccessRevoked else { + await trace( + "enqueue Player[\(gameID.uuidString.prefix(8))] skipped " + + "(access revoked) reason=\(reason)" + ) + return + } let engine = info.scope == 1 ? sharedEngine : privateEngine guard let engine else { return } let recordName = RecordSerializer.recordName(forPlayerInGame: gameID, authorID: authorID)