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:
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(