crossmate

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

commit 8ccdffc4ed706c5293146c4d10a9e8ec6f38d48b
parent 44d868c84b6eebac26d95a13597fdcf6757088d0
Author: Michael Camilleri <[email protected]>
Date:   Tue, 30 Jun 2026 08:49:34 +0900

Harden reversible friend blocking

Blocking or unblocking a friend could look successful locally before the
CloudKit share permission change had actually landed. A failed
permission write then left the current device, and possibly sibling
devices through the block Decision, believing a state that the server
was not enforcing.

This commit makes the permission update the first authoritative step.
The local FriendEntity state and account-zone block Decision are written
only after the inbox share has been downgraded or restored, so a
CloudKit failure surfaces as a real failure without publishing a false
block state.

The friend mailbox send paths now also wait until this device has
accepted the friend's inbox as its outbox. Inviting a friend checks that
readiness before adding them to the game share, avoiding a stranded seat
when the friendship row exists but the mailbox bootstrap is still
incomplete. Name and invite-key fan-out likewise skip unaccepted
outboxes, and restored devices can recover a missing marker by probing
the shared zone directly.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Services/AccountPushCoordinator.swift | 1+
MCrossmate/Services/InviteCoordinator.swift | 5+++++
MCrossmate/Services/PlayerNamePublisher.swift | 3++-
MCrossmate/Sync/FriendController.swift | 161++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
MCrossmate/Sync/FriendZone.swift | 23+++++++++++++++++++++++
MCrossmate/Views/GameList/GameListView.swift | 2+-
MTests/Unit/PlayerNamePublisherTests.swift | 3+++
7 files changed, 146 insertions(+), 52 deletions(-)

diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -238,6 +238,7 @@ final class AccountPushCoordinator { !friendAuthorID.isEmpty else { return nil } let pairKey = friend.pairKey ?? FriendZone.pairKey(localAuthorID, friendAuthorID) + guard FriendZone.outboxAccepted(pairKey: pairKey) else { return nil } // The friend decrypts our invite pushes by reading our key from // their inbox — our outbox (shared DB, scope 1). return ( diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -100,6 +100,11 @@ final class InviteCoordinator { // then merges in as a background update. let puzzleSource = game?.puzzleSource + // Make sure the pair's mailbox handshake has completed before mutating + // the game share. Otherwise the friend can be added as a participant but + // never receive the invite Ping that tells them about the seat. + try await friendController.ensureOutboxReady(friendAuthorID: friendAuthorID) + let url = try await shareController.addFriendParticipant( toGameID: gameID, userRecordName: friendAuthorID diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift @@ -172,7 +172,8 @@ final class PlayerNamePublisher { // Our name is told to a friend by writing into their inbox — // our outbox (shared DB, scope 1). guard let pairKey = friend.pairKey, - let authorID = friend.authorID + let authorID = friend.authorID, + FriendZone.outboxAccepted(pairKey: pairKey) else { return nil } return ( FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID), diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -59,6 +59,7 @@ final class FriendController { case missingShareURLInPayload case friendNotFound case friendBlocked + case friendshipNotReady case payloadEncodingFailed } @@ -84,7 +85,7 @@ final class FriendController { else { return } let pairKey = FriendZone.pairKey(localAuthorID, remoteAuthorID) - if inboxEstablished(pairKey: pairKey) { return } + if FriendZone.inboxEstablished(pairKey: pairKey) { return } let zoneID = FriendZone.inboxZoneID(pairKey: pairKey) syncMonitor?.recordStart("establish friendship") @@ -97,7 +98,7 @@ final class FriendController { // it or re-enqueuing the `.friend` Ping — already delivered. if try await existingZoneWideShare(zoneID: zoneID) != nil { persistFriend(authorID: remoteAuthorID, pairKey: pairKey) - markInboxEstablished(pairKey: pairKey) + FriendZone.markInboxEstablished(pairKey: pairKey) syncMonitor?.recordSuccess("establish friendship") return } @@ -112,14 +113,14 @@ final class FriendController { // Lost the create race to a sibling between the check above and // this save. The share now exists; adopt it without re-sending. persistFriend(authorID: remoteAuthorID, pairKey: pairKey) - markInboxEstablished(pairKey: pairKey) + FriendZone.markInboxEstablished(pairKey: pairKey) syncMonitor?.recordSuccess("establish friendship") return } guard let url = share.url else { throw FriendError.missingShareURL } persistFriend(authorID: remoteAuthorID, pairKey: pairKey) - markInboxEstablished(pairKey: pairKey) + FriendZone.markInboxEstablished(pairKey: pairKey) let payload = FriendZone.BootstrapPayload( friendShareURL: url.absoluteString, @@ -183,7 +184,7 @@ final class FriendController { // friendship row exists: our own `establish` may have created the row // already, but we still owe the accept that gives us write access to // their inbox (our outbox). - if outboxAccepted(pairKey: payload.pairKey) { return } + if FriendZone.outboxAccepted(pairKey: payload.pairKey) { return } guard let url = URL(string: payload.friendShareURL) else { return } syncMonitor?.recordStart("accept friendship") @@ -193,7 +194,7 @@ final class FriendController { // The accepted zone is the friend's inbox — our outbox. Its owner // name is the friend's CloudKit user-record name. let outboxZoneID = metadata.share.recordID.zoneID - markOutboxAccepted(pairKey: payload.pairKey) + FriendZone.markOutboxAccepted(pairKey: payload.pairKey) persistFriend(authorID: payload.ownerAuthorID, pairKey: payload.pairKey) if let localAuthorID, !localAuthorID.isEmpty { // Tell the friend our name by writing into their inbox (our @@ -262,7 +263,7 @@ final class FriendController { } var allSucceeded = true - for (pairKey, friendAuthorID) in pairs where !inboxEstablished(pairKey: pairKey) { + for (pairKey, friendAuthorID) in pairs where !FriendZone.inboxEstablished(pairKey: pairKey) { let inboxZoneID = FriendZone.inboxZoneID(pairKey: pairKey) syncMonitor?.recordStart("migrate friendship") do { @@ -273,7 +274,7 @@ final class FriendController { // our inbox, with the friend joined as a participant. Nothing // to announce; we accept *their* new inbox when their // announcement arrives via `applyFriendPing`. - markInboxEstablished(pairKey: pairKey) + FriendZone.markInboxEstablished(pairKey: pairKey) syncMonitor?.recordSuccess("migrate friendship") continue } @@ -298,8 +299,8 @@ final class FriendController { syncMonitor?.recordError("migrate friendship", FriendError.missingShareURL) continue } - markInboxEstablished(pairKey: pairKey) - markOutboxAccepted(pairKey: pairKey) + FriendZone.markInboxEstablished(pairKey: pairKey) + FriendZone.markOutboxAccepted(pairKey: pairKey) await syncEngine.enqueueFriendZonePing( kind: .friend, gameID: Self.migrationSentinelGameID(pairKey: pairKey), @@ -364,6 +365,7 @@ final class FriendController { } guard !friend.isBlocked else { throw FriendError.friendBlocked } guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } + try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: friendAuthorID) let payload = FriendZone.InvitePayload( gameShareURL: gameShareURL.absoluteString, @@ -409,6 +411,7 @@ final class FriendController { } guard !friend.isBlocked else { throw FriendError.friendBlocked } guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } + try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: inviterAuthorID) await syncEngine.enqueueFriendZonePing( kind: .decline, @@ -476,20 +479,6 @@ final class FriendController { let pairKey = friend.pairKey else { return } let version = friend.blockVersion + 1 - friend.isBlocked = blocked - friend.blockVersion = version - try ctx.save() - - // Converge the UI flag across our own devices. Enforcement itself is the - // server-side share permission change below, which is account-wide, so - // this Decision only needs to carry the flag (versioned, so a stale copy - // can't resurrect a cleared block). - await syncEngine.enqueueDecision( - kind: RecordSerializer.blockDecisionKind, - key: friendAuthorID, - payload: blocked ? "1" : "0", - version: version - ) let label = blocked ? "block friend" : "unblock friend" syncMonitor?.recordStart(label) @@ -499,6 +488,20 @@ final class FriendController { pairKey: pairKey, permission: blocked ? .readOnly : .readWrite ) + friend.isBlocked = blocked + friend.blockVersion = version + try ctx.save() + + // Converge the UI flag across our own devices. Enforcement itself is + // the server-side share permission change above, which is + // account-wide, so this Decision only needs to carry the flag + // (versioned, so a stale copy can't resurrect a cleared block). + await syncEngine.enqueueDecision( + kind: RecordSerializer.blockDecisionKind, + key: friendAuthorID, + payload: blocked ? "1" : "0", + version: version + ) syncMonitor?.recordSuccess(label) } catch { syncMonitor?.recordError(label, error) @@ -529,6 +532,33 @@ final class FriendController { _ = try await database.save(share) } + /// Ensures this device has accepted the friend's inbox share before trying + /// to write a Ping/Decision to it. The UserDefaults marker is the fast path; + /// a direct shared-zone probe covers restored devices or marker loss after + /// CloudKit has already delivered the accepted zone. + func ensureOutboxReady(friendAuthorID: String) async throws { + 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 else { + throw FriendError.friendNotFound + } + guard !friend.isBlocked else { throw FriendError.friendBlocked } + guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } + try await ensureOutboxAccepted(pairKey: pairKey, friendAuthorID: friendAuthorID) + } + + private func ensureOutboxAccepted(pairKey: String, friendAuthorID: String) async throws { + if FriendZone.outboxAccepted(pairKey: pairKey) { return } + let zoneID = FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID) + if try await zoneExists(zoneID, in: container.sharedCloudDatabase) { + FriendZone.markOutboxAccepted(pairKey: pairKey) + return + } + throw FriendError.friendshipNotReady + } + // MARK: - Rename /// Sets (or, with an empty string, clears) the user's private nickname @@ -611,6 +641,42 @@ final class FriendController { } } + private func zoneExists(_ zoneID: CKRecordZone.ID, in database: CKDatabase) async throws -> Bool { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Bool, Error>) in + var exists = false + var perZoneError: Error? + let op = CKFetchRecordZonesOperation(recordZoneIDs: [zoneID]) + op.qualityOfService = .userInitiated + op.perRecordZoneResultBlock = { _, result in + switch result { + case .success: + exists = true + case .failure(let error as CKError) + where error.code == .zoneNotFound || error.code == .unknownItem: + exists = false + case .failure(let error): + perZoneError = error + } + } + op.fetchRecordZonesResultBlock = { result in + switch result { + case .success: + if let perZoneError { + cont.resume(throwing: perZoneError) + } else { + cont.resume(returning: exists) + } + case .failure(let error as CKError) + where error.code == .zoneNotFound || error.code == .unknownItem: + cont.resume(returning: false) + case .failure(let error): + cont.resume(throwing: error) + } + } + database.add(op) + } + } + private func fetchParticipant( forUserRecordName recordName: String ) async throws -> CKShare.Participant { @@ -671,32 +737,6 @@ final class FriendController { } } - // MARK: - Bootstrap dedup markers - - // The two bootstrap halves are independent and each rides a Ping that is - // re-fetched on every sync, so each needs its own idempotency guard. These - // are per-device markers (a sibling re-runs the work, but adopts the - // existing CloudKit share / accepted zone rather than duplicating it). - - private static let inboxEstablishedPrefix = "friend.inboxEstablished." - private static let outboxAcceptedPrefix = "friend.outboxAccepted." - - private func inboxEstablished(pairKey: String) -> Bool { - UserDefaults.standard.bool(forKey: Self.inboxEstablishedPrefix + pairKey) - } - - private func markInboxEstablished(pairKey: String) { - UserDefaults.standard.set(true, forKey: Self.inboxEstablishedPrefix + pairKey) - } - - private func outboxAccepted(pairKey: String) -> Bool { - UserDefaults.standard.bool(forKey: Self.outboxAcceptedPrefix + pairKey) - } - - private func markOutboxAccepted(pairKey: String) { - UserDefaults.standard.set(true, forKey: Self.outboxAcceptedPrefix + pairKey) - } - // MARK: - Core Data /// Records the friendship row. The display name is deliberately *not* @@ -767,3 +807,24 @@ final class FriendController { } } } + +extension FriendController.FriendError: LocalizedError { + var errorDescription: String? { + switch self { + case .invalidShareRecord: + return "The friendship record in iCloud is invalid." + case .missingShareURL, .missingShareURLInPayload: + return "The friendship share link is missing." + case .participantNotFound: + return "That player could not be found in iCloud." + case .friendNotFound: + return "That Crossmate is not available yet." + case .friendBlocked: + return "That Crossmate is blocked." + case .friendshipNotReady: + return "This Crossmate is still syncing. Try again in a moment." + case .payloadEncodingFailed: + return "The invitation could not be prepared." + } + } +} diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift @@ -55,6 +55,29 @@ enum FriendZone { CKRecordZone.ID(zoneName: zoneName(pairKey: pairKey), ownerName: friendAuthorID) } + // The two bootstrap halves are independent and each rides a Ping that is + // re-fetched on every sync, so each needs its own idempotency guard. These + // are per-device markers: a sibling or restored device may still need to + // adopt the CloudKit state locally. + private static let inboxEstablishedPrefix = "friend.inboxEstablished." + private static let outboxAcceptedPrefix = "friend.outboxAccepted." + + static func inboxEstablished(pairKey: String) -> Bool { + UserDefaults.standard.bool(forKey: inboxEstablishedPrefix + pairKey) + } + + static func markInboxEstablished(pairKey: String) { + UserDefaults.standard.set(true, forKey: inboxEstablishedPrefix + pairKey) + } + + static func outboxAccepted(pairKey: String) -> Bool { + UserDefaults.standard.bool(forKey: outboxAcceptedPrefix + pairKey) + } + + static func markOutboxAccepted(pairKey: String) { + UserDefaults.standard.set(true, forKey: outboxAcceptedPrefix + pairKey) + } + /// Owner election: the user whose record name sorts first creates and /// owns the zone; the other accepts the share. Deterministic on both /// devices. Equal IDs (same user) can never be friends — returns false. diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -241,7 +241,7 @@ struct GameListView: View { Button("Cancel", role: .cancel) {} } message: { let name = blockTarget?.resolvedInviterName ?? "this player" - Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be removed from this device.") + Text("You won't receive further invites from \(name), and any puzzles they currently share with you will be hidden from your list. You can unblock them later to bring everything back.") } .alert("Set Profile Name", isPresented: $showingNamePrompt) { TextField("Name", text: $nameDraft) diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift @@ -44,6 +44,9 @@ struct PlayerNamePublisherTests { friend.isBlocked = blocked friend.createdAt = Date() try ctx.save() + if !blocked { + FriendZone.markOutboxAccepted(pairKey: "pair-\(authorID)") + } } private func makeBroadcaster(