commit b638eee5c295aa008dbef288a2bb3e5437765eb0
parent f095843f49522fd702ed5ba8b3df20f1f34dc396
Author: Michael Camilleri <[email protected]>
Date: Wed, 20 May 2026 15:06:03 +0900
Direct and consume invite pings
Friend invites now write .invite pings with the recipient's author ID as
addressee, and the Game List only ingests invites directed at the local author.
Accepted or stale invites delete both the local InviteEntity and the source
friend-zone Ping, so old rows cannot reappear after sync catch-up.
This commit also adds a one-shot legacy invite purge that removes older
broadcast .invite pings from known friend zones, matching the existing
lease-ping cleanup pattern.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
5 files changed, 170 insertions(+), 12 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -5,6 +5,17 @@ import UserNotifications
@MainActor
final class AppServices {
+ enum InviteAcceptanceError: LocalizedError {
+ case unavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .unavailable:
+ return "This invite is no longer available. Ask the sender to invite you again."
+ }
+ }
+ }
+
enum ReadCursorPublishMode {
case activeLease
case currentTime
@@ -1045,12 +1056,15 @@ final class AppServices {
/// Upserts a durable `InviteEntity` for each inbound `.invite` Ping so the
/// Game List's "Invited" section survives the Ping being GC'd. Skips
- /// self-authored invites, invites from blocked friends, games already
- /// joined, and any `pingRecordName` already seen (a declined row is a
- /// tombstone that prevents resurrection).
+ /// self-authored invites, invites not directed to this author, invites
+ /// from blocked friends, games already joined, and any `pingRecordName`
+ /// already seen (a declined row is a tombstone that prevents
+ /// resurrection).
private func applyInvitePings(_ pings: [Ping]) {
let invites = pings.filter {
- $0.kind == .invite && $0.authorID != identity.currentID
+ $0.kind == .invite &&
+ $0.authorID != identity.currentID &&
+ $0.addressee == identity.currentID
}
guard !invites.isEmpty else { return }
@@ -1106,16 +1120,36 @@ final class AppServices {
/// Accepts a pending game invite: fetches the share metadata, joins via
/// the existing share-accept path, then drops the local `InviteEntity`
- /// (the game now represents it). Surfaced via `\.acceptInvite`.
+ /// (the game now represents it). If CloudKit says the share URL no longer
+ /// exists, the durable invite row is stale, so it is removed as well.
+ /// Surfaced via `\.acceptInvite`.
func acceptInvite(shareURL: String, pingRecordName: String) async throws {
guard let url = URL(string: shareURL) else {
throw FriendController.FriendError.missingShareURLInPayload
}
- try await cloudService.acceptShare(url: url)
+ do {
+ try await cloudService.acceptShare(url: url)
+ } catch let error as CKError where error.code == .unknownItem {
+ await deleteInviteAndPing(pingRecordName: pingRecordName)
+ syncMonitor.note("accept invite: removed stale invite \(pingRecordName)")
+ throw InviteAcceptanceError.unavailable
+ }
+ await deleteInviteAndPing(pingRecordName: pingRecordName)
+ }
+
+ private func deleteInviteAndPing(pingRecordName: String) async {
let ctx = persistence.viewContext
let req = NSFetchRequest<InviteEntity>(entityName: "InviteEntity")
req.predicate = NSPredicate(format: "pingRecordName == %@", pingRecordName)
- for invite in (try? ctx.fetch(req)) ?? [] { ctx.delete(invite) }
+ for invite in (try? ctx.fetch(req)) ?? [] {
+ if let inviterAuthorID = invite.inviterAuthorID {
+ await friendController.deleteInvitePing(
+ fromFriendAuthorID: inviterAuthorID,
+ recordName: pingRecordName
+ )
+ }
+ ctx.delete(invite)
+ }
if ctx.hasChanges { try? ctx.save() }
}
@@ -1182,10 +1216,13 @@ final class AppServices {
let center = UNUserNotificationCenter.current()
for ping in playerFacingPings {
+ if ping.kind == .invite, ping.addressee != identity.currentID {
+ continue
+ }
// A directed ping (`addressee` set) targets one player by
// authorID. Ignore one addressed to someone else — another user's
- // device receives and consumes it. nil ⇒ broadcast (legacy /
- // mixed-version peers): every recipient still acts on it.
+ // device receives and consumes it. nil ⇒ broadcast, which is now
+ // legacy and ignored for `.invite`.
if let addressee = ping.addressee, addressee != identity.currentID {
continue
}
@@ -1197,7 +1234,7 @@ final class AppServices {
// once handled — shown, suppressed, or a duplicate — delete it so
// it stops re-notifying and the deletion withdraws any copy our
// sibling devices showed. Broadcast pings are left as-is.
- let consume = ping.addressee != nil
+ let consume = ping.addressee != nil && ping.kind != .invite
func consumeIfDirected() async {
guard consume else { return }
await syncEngine.deletePing(recordName: ping.recordName, gameID: ping.gameID)
diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift
@@ -220,12 +220,33 @@ final class FriendController {
gameTitle: gameTitle,
authorID: inviterAuthorID,
playerName: inviterName,
+ addressee: friendAuthorID,
friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
friendZoneScope: friend.databaseScope,
payload: encoded
)
}
+ /// Consume-deletes an `.invite` Ping from the pairwise friend zone after
+ /// the invite has been accepted or found stale. This removes the source
+ /// record so the invite cannot be re-created on the recipient's devices.
+ func deleteInvitePing(fromFriendAuthorID friendAuthorID: String, recordName: String) async {
+ let ctx = persistence.viewContext
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID)
+ req.fetchLimit = 1
+ guard let friend = try? ctx.fetch(req).first,
+ let zoneName = friend.friendZoneName,
+ let ownerName = friend.friendZoneOwnerName
+ else { return }
+
+ await syncEngine.deletePing(
+ recordName: recordName,
+ zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName),
+ databaseScope: friend.databaseScope
+ )
+ }
+
// MARK: - Block
/// Marks the friend blocked and tears down the channel so nothing further
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -292,6 +292,7 @@ actor SyncEngine {
// CKDatabase.save is idempotent for an existing subscriptionID.
Task { await ensureDatabaseSubscriptions() }
Task { await purgeLegacyLeasePings_v1() }
+ Task { await purgeLegacyInvitePings_v1() }
}
private func ensureDatabaseSubscriptions() async {
@@ -540,6 +541,56 @@ actor SyncEngine {
}
}
+ /// One-shot cleanup of legacy broadcast `.invite` pings from pairwise
+ /// friend zones. Current invites are addressed with `addressee`; older
+ /// ones were broadcast within the friend zone and can resurrect stale
+ /// Game List rows after a device restores or replays zone history.
+ func purgeLegacyInvitePings_v1() async {
+ guard NotificationState.legacyInvitePurgeNeeded() else { return }
+ let privateZones = friendZoneIDs(forScope: 0)
+ let sharedZones = friendZoneIDs(forScope: 1)
+ do {
+ let privateDeleted = try await purgeLegacyInvitePings(
+ in: privateZones,
+ database: container.privateCloudDatabase
+ )
+ let sharedDeleted = try await purgeLegacyInvitePings(
+ in: sharedZones,
+ database: container.sharedCloudDatabase
+ )
+ NotificationState.markLegacyInvitePurged()
+ let total = privateDeleted + sharedDeleted
+ if total > 0 {
+ await trace("legacy-invite purge: deleted \(total) record(s)")
+ }
+ } catch {
+ await trace("legacy-invite purge failed: \(describe(error))")
+ }
+ }
+
+ private func purgeLegacyInvitePings(
+ in zoneIDs: [CKRecordZone.ID],
+ database: CKDatabase
+ ) async throws -> Int {
+ var deleted = 0
+ let predicate = NSPredicate(format: "kind == %@", PingKind.invite.rawValue)
+ for zoneID in zoneIDs {
+ let records = try await queryRecords(
+ type: "Ping",
+ database: database,
+ zoneID: zoneID,
+ predicate: predicate,
+ desiredKeys: ["addressee"]
+ )
+ let legacyIDs = records
+ .filter { ($0["addressee"] as? String)?.isEmpty != false }
+ .map(\.recordID)
+ try await deleteRecords(withIDs: legacyIDs, in: database)
+ deleted += legacyIDs.count
+ }
+ return deleted
+ }
+
/// Registers an `.invite` Ping into an existing *friend* zone. Unlike
/// `enqueuePing`, the target zone is the friend zone (not the game zone),
/// so the zone and engine are passed in explicitly: `scope == 1` means we
@@ -553,6 +604,7 @@ actor SyncEngine {
gameTitle: String,
authorID: String,
playerName: String,
+ addressee: String,
friendZoneID: CKRecordZone.ID,
friendZoneScope: Int16,
payload: String
@@ -577,7 +629,7 @@ actor SyncEngine {
kind: .invite,
scope: nil,
payload: payload,
- addressee: nil
+ addressee: addressee
)
let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
@@ -637,6 +689,19 @@ actor SyncEngine {
sendChangesDetached(on: engine)
}
+ /// Deletes a Ping from a known non-game zone, currently used for accepted
+ /// friend invites. Unlike `deletePing(recordName:gameID:)`, the GameEntity
+ /// may not exist before acceptance, so the caller supplies the friend-zone
+ /// route directly.
+ func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: Int16) {
+ let engine = databaseScope == 1 ? sharedEngine : privateEngine
+ guard let engine else { return }
+ pendingPings.removeValue(forKey: recordName)
+ let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
+ engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)])
+ sendChangesDetached(on: engine)
+ }
+
private nonisolated static func notificationTitle(for entity: GameEntity?) -> String {
guard let entity else { return "" }
return PuzzleNotificationText.title(
@@ -1942,6 +2007,28 @@ actor SyncEngine {
}
}
+ private nonisolated func friendZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] {
+ let ctx = persistence.container.newBackgroundContext()
+ return ctx.performAndWait {
+ let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity")
+ req.predicate = NSPredicate(
+ format: "databaseScope == %d AND isBlocked == NO",
+ scope
+ )
+ var seen = Set<String>()
+ var result: [CKRecordZone.ID] = []
+ for friend in (try? ctx.fetch(req)) ?? [] {
+ guard let zoneName = friend.friendZoneName,
+ let ownerName = friend.friendZoneOwnerName
+ else { continue }
+ let key = "\(ownerName)|\(zoneName)"
+ guard seen.insert(key).inserted else { continue }
+ result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName))
+ }
+ return result
+ }
+ }
+
/// Extracts the game UUID from any of our record name formats:
/// `game-<UUID>`, `moves-<UUID>-…`, `player-<UUID>-…`, `ping-<UUID>-…`.
private nonisolated func gameID(fromRecordName name: String) -> UUID? {
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -289,7 +289,7 @@ struct GameListView: View {
do {
try await acceptInvite(url, ping)
} catch {
- inviteError = String(describing: error)
+ inviteError = error.localizedDescription
}
}
diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift
@@ -152,6 +152,7 @@ enum NotificationState {
}
private static let legacyLeasePurgeKey = "migration.legacyLeasePurge.v1"
+ private static let legacyInvitePurgeKey = "migration.legacyInvitePurge.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
@@ -166,4 +167,16 @@ enum NotificationState {
static func markLegacyLeasePurged() {
defaults?.set(true, forKey: legacyLeasePurgeKey)
}
+
+ /// True if the one-shot cleanup of legacy broadcast `.invite` pings has
+ /// not yet run successfully on this device.
+ static func legacyInvitePurgeNeeded() -> Bool {
+ defaults?.bool(forKey: legacyInvitePurgeKey) == false
+ }
+
+ /// Records that the legacy invite purge completed successfully so the next
+ /// launch skips it.
+ static func markLegacyInvitePurged() {
+ defaults?.set(true, forKey: legacyInvitePurgeKey)
+ }
}