crossmate

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

commit 89e202971943b36aa1942ccd0689e6ffe661501d
parent 77ddc8c530c8137a3fbce5ee83ae20b89362ff38
Author: Michael Camilleri <[email protected]>
Date:   Thu, 23 Jul 2026 23:50:14 +0900

Confirm CloudKit invitations and expose join failures

Invitations could appear successful after the CKShare saved even when
the friend-zone Ping later failed, leaving an unusable participant seat
and no clear feedback. Accepted-share record errors could likewise be
flattened into a misleading synchronisation timeout.

This commit keeps invitations queued until CloudKit confirms the Ping.
Terminal failures roll back only the participant added by that attempt,
return the friend to a retryable state, and surface an 'Invitation Not
Sent' callout or Game List banner. Quota failures use dedicated iCloud
storage guidance, while transient failures honour CKRetryAfterKey and
retain their durable outbox entry.

Join fetches now propagate per-record CloudKit errors and distinguish a
removed puzzle, temporary unavailability and failure after acceptance.
Sharing diagnostics record scrubbed CKShare state and report pending
invitations so future failures can be traced without exposing full
CloudKit identities.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/CrossmateApp.swift | 19+++++++++++++++----
MCrossmate/Services/AppActions.swift | 13+++++++++++--
MCrossmate/Services/AppServices.swift | 46+++++++++++++++++++++++++++++++++++++++++++---
MCrossmate/Services/CloudService.swift | 147++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
MCrossmate/Services/InviteCoordinator.swift | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
MCrossmate/Services/InviteDeliveryStore.swift | 38+++++++++++++++++++++++++++++++++++---
MCrossmate/Sync/CloudDiagnostics.swift | 4+++-
MCrossmate/Sync/CloudQuery.swift | 21+++++++++++++++++----
MCrossmate/Sync/DiagnosticsReport.swift | 4++++
MCrossmate/Sync/FriendController.swift | 7+++++--
MCrossmate/Sync/ShareController.swift | 44++++++++++++++++++++++++++++++++++++++++++--
MCrossmate/Sync/SyncEngine.swift | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
MCrossmate/Views/Friends/FriendPickerView.swift | 59+++++++++++++++++++++++++++++++++++++++++------------------
ACrossmate/Views/Friends/InvitationFailureCallout.swift | 32++++++++++++++++++++++++++++++++
MCrossmate/Views/GameList/GameListView.swift | 25+++++++++++++++++++++----
MCrossmate/Views/GameList/GameShareItem.swift | 57++++++++++++++++++++++++++++++++++++++++++++++++++++++---
MCrossmate/Views/Settings/DiagnosticsView.swift | 12++++++++++++
ATests/Unit/CloudJoinFailureTests.swift | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/Sync/PendingChangeReapTests.swift | 40++++++++++++++++++++++++++++++++++++++--
20 files changed, 909 insertions(+), 92 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -126,6 +126,7 @@ 7E6ED856CE78A774B061CA60 /* FriendEncryptionKeyDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833AAFBA0094BAB27E13FE29 /* FriendEncryptionKeyDirectory.swift */; }; 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; + 8171E5B23FEC5FB63029A640 /* CloudJoinFailureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E6BB07A54A3C3AF6F210F49 /* CloudJoinFailureTests.swift */; }; 8225918652DCC822CA1C862F /* PendingEditFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D491B7232333AA8957732387 /* PendingEditFlagTests.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; 82D1714DC441E4943E96C882 /* PuzzleFileReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9EE11280592CA66C2BEC71A /* PuzzleFileReaderTests.swift */; }; @@ -208,6 +209,7 @@ CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; CF1DC343A5D3110EDFA703AB /* LastUpdatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF3B7E191D571FD800A4D719 /* LastUpdatedView.swift */; }; CF56BBB90855367CB85FEB43 /* PUZToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B369788E0FEA0DCE1B125816 /* PUZToXDConverter.swift */; }; + D05F1B226E67A31218BA4E3A /* InvitationFailureCallout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2494B62495C8048ED64072E2 /* InvitationFailureCallout.swift */; }; D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */; }; D240BF6498A9148855DB7734 /* EngagementLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB0580C9B7C778F34BE6AC2 /* EngagementLifecycle.swift */; }; D2AC1D9BD7E387B06B9B8A0E /* PuzzleHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */; }; @@ -315,6 +317,7 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 23A7390848B1943C80142934 /* PuzzleGridStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleGridStats.swift; sourceTree = "<group>"; }; 23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColorTests.swift; sourceTree = "<group>"; }; + 2494B62495C8048ED64072E2 /* InvitationFailureCallout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitationFailureCallout.swift; sourceTree = "<group>"; }; 24A4B5C8EC4A46906C07F819 /* GameEntity+ContentKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GameEntity+ContentKey.swift"; sourceTree = "<group>"; }; 275DB12E9D81DD48B9025718 /* GameSyncVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameSyncVersion.swift; sourceTree = "<group>"; }; 27ECEA51DE42D07495744EF8 /* JournalReplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplay.swift; sourceTree = "<group>"; }; @@ -369,6 +372,7 @@ 5C838C184A0C7B1B0A9821CE /* PlayerRecordPresenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRecordPresenceTests.swift; sourceTree = "<group>"; }; 5CBEE60E9D850739CA3D01AA /* MarketingScreenshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingScreenshots.swift; sourceTree = "<group>"; }; 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; }; + 5E6BB07A54A3C3AF6F210F49 /* CloudJoinFailureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudJoinFailureTests.swift; sourceTree = "<group>"; }; 603E6FC55F1BD944592379D2 /* ReplayCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayCacheTests.swift; sourceTree = "<group>"; }; 605CA0FC7AF069CE3A3B38C1 /* RecentChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentChanges.swift; sourceTree = "<group>"; }; 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; }; @@ -613,6 +617,7 @@ 48EC33BC7DDAC3B7F29C3F02 /* AppDelegatePushBufferTests.swift */, B9EA9CF96312BFF5340CE2A7 /* ArchiveTests.swift */, 3059A4C23D040E0D1F258B92 /* CellAccessibilityDescriberTests.swift */, + 5E6BB07A54A3C3AF6F210F49 /* CloudJoinFailureTests.swift */, 29686FFBC5BC8BCCF8CCC49A /* DriveMonitorTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, @@ -718,6 +723,7 @@ children = ( E78C275F8A90E3E3EEF190CC /* FriendPickerView.swift */, 298A9C54A1CC753E860E174E /* FriendsView.swift */, + 2494B62495C8048ED64072E2 /* InvitationFailureCallout.swift */, ); path = Friends; sourceTree = "<group>"; @@ -1072,6 +1078,7 @@ DB098F40C6950E29B4BF10A7 /* ArchiveTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 7BE53C851A311A7B6255B45E /* CellAccessibilityDescriberTests.swift in Sources */, + 8171E5B23FEC5FB63029A640 /* CloudJoinFailureTests.swift in Sources */, 8C37D748EBFD75DACCB992C3 /* DriveMonitorTests.swift in Sources */, 328309D8CC72CCB5623FB2A1 /* EngagementCoordinatorTests.swift in Sources */, 3B3F30AA1B7AA6D4465CB537 /* EngagementMessageAuthenticatorTests.swift in Sources */, @@ -1226,6 +1233,7 @@ 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */, F8D37DBE75D7B3F039A8FAC8 /* ImportedBrowseView.swift in Sources */, 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, + D05F1B226E67A31218BA4E3A /* InvitationFailureCallout.swift in Sources */, 59230713D85AE6895852B06A /* InviteCoordinator.swift in Sources */, FE447FF4339EA0BF874B1114 /* InviteDeliveryStore.swift in Sources */, 7D9337A19747C79070AB3D59 /* InviteEntity+DisplayName.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -698,8 +698,11 @@ struct RootView: View { // List rather than bouncing the user back in silence. guard !Task.isCancelled else { return } withAnimation { pendingJoin = nil } - let code = (error as? CKError)?.code - let gone = code == .unknownItem || code == .zoneNotFound + let acceptedError = error as? AcceptedShareError + let code = CloudService.cloudErrorCode(error) + let gone = acceptedError?.kind == .removed + || code == .unknownItem + || code == .zoneNotFound let versionMismatch = (error as? CloudServiceError) == .containerVersionMismatch services.eventLog.note( "share link join failed: \(error.localizedDescription)", @@ -711,10 +714,18 @@ struct RootView: View { severity: (gone || versionMismatch) ? .warning : .error, title: versionMismatch ? "Update Needed" - : (gone ? "Puzzle Removed" : "Accepting Failed"), + : (gone + ? "Puzzle Removed" + : (acceptedError?.isQuotaExceeded == true + ? CloudFailureCopy.quotaJoinTitle + : (acceptedError == nil + ? "Accepting Failed" + : "Puzzle Unavailable"))), body: gone ? "This puzzle was removed." - : error.localizedDescription, + : (acceptedError?.isQuotaExceeded == true + ? CloudFailureCopy.quotaJoinBody + : error.localizedDescription), dismissal: .manual )) } diff --git a/Crossmate/Services/AppActions.swift b/Crossmate/Services/AppActions.swift @@ -35,14 +35,23 @@ final class AppActions { ) } - func markInviteFailed(gameID: UUID, friendAuthorID: String) { + func markInviteFailed(gameID: UUID, friendAuthorID: String, error: Error) { services.inviteDeliveries.markFailed( recordName: nil, gameID: gameID, - friendAuthorID: friendAuthorID + friendAuthorID: friendAuthorID, + failure: InviteDeliveryFailure(error: error) ) } + func beginInvitationPresentation(gameID: UUID) { + services.inviteDeliveries.beginPresentation(for: gameID) + } + + func endInvitationPresentation(gameID: UUID) { + services.inviteDeliveries.endPresentation(for: gameID) + } + func acceptInvite(shareURL: String, pingRecordName: String) async throws { _ = try await services.invites.acceptInvite( shareURL: shareURL, diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -1144,11 +1144,34 @@ final class AppServices { friendAuthorID: update.addressee ) case .failed: + let failure = update.failure ?? .other self.inviteDeliveries.markFailed( recordName: update.recordName, gameID: update.gameID, - friendAuthorID: update.addressee + friendAuthorID: update.addressee, + failure: failure ) + if !self.inviteDeliveries.hasActivePresentation(for: update.gameID) { + self.announcements.post(Announcement( + id: InviteDeliveryStore.failureAnnouncementID( + gameID: update.gameID, + friendAuthorID: update.addressee + ), + scope: .global, + severity: .error, + title: String(localized: failure.title), + body: String(localized: failure.body), + dismissal: .manual + )) + } + if update.rollbackParticipantOnFailure { + Task { @MainActor [weak self] in + await self?.invites.rollbackUndeliveredInvite( + gameID: update.gameID, + friendAuthorID: update.addressee + ) + } + } } } @@ -2436,8 +2459,25 @@ final class AppServices { } } catch { // The CloudService already recorded the detailed CloudKit - // failure; OS-delivered share acceptances have no caller to - // surface the error to, so keep draining the queue. + // failure. The OS route has no caller to present it, so surface + // the accepted-but-unavailable distinction here and keep + // draining the queue. + if error is AcceptedShareError { + let quotaExceeded = (error as? AcceptedShareError)? + .isQuotaExceeded == true + announcements.post(Announcement( + id: "os-share-joined-unavailable", + scope: .global, + severity: .error, + title: quotaExceeded + ? CloudFailureCopy.quotaJoinTitle + : "Puzzle Unavailable", + body: quotaExceeded + ? CloudFailureCopy.quotaJoinBody + : error.localizedDescription, + dismissal: .manual + )) + } } } } diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -1,5 +1,11 @@ import CloudKit +enum CloudFailureCopy { + static let quotaJoinTitle = "Puzzle Unavailable" + static let quotaJoinBody = + "The puzzle could not be joined properly. This could be because your friend's iCloud storage is full." +} + extension Notification.Name { static let cloudShareAcceptanceStarted = Notification.Name("cloudShareAcceptanceStarted") static let cloudShareAcceptanceCompleted = Notification.Name("cloudShareAcceptanceCompleted") @@ -21,6 +27,29 @@ enum CloudServiceError: Error, LocalizedError, Equatable { } } +/// The share itself was accepted, but Crossmate could not materialize its +/// playable Game record. Keeping this distinct from an acceptance failure lets +/// callers tell the truth: retrying does not need to accept the share again. +struct AcceptedShareError: Error, LocalizedError { + enum Kind: Equatable { + case removed + case temporarilyUnavailable + case unavailable + } + + let kind: Kind + let underlyingError: Error + + var errorDescription: String? { + if isQuotaExceeded { return CloudFailureCopy.quotaJoinBody } + return underlyingError.localizedDescription + } + + var isQuotaExceeded: Bool { + CloudService.cloudErrorCode(underlyingError) == .quotaExceeded + } +} + @MainActor final class CloudService { private let ckContainer: CKContainer @@ -126,12 +155,14 @@ final class CloudService { let sharedGameID = RecordSerializer.gameID( fromGameRecordName: sharedZoneID.zoneName ) + var shareWasAccepted = false do { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in let op = CKAcceptSharesOperation(shareMetadatas: [metadata]) op.acceptSharesResultBlock = { result in cont.resume(with: result) } ckContainer.add(op) } + shareWasAccepted = true if let sharedGameID { // When the invite carried the puzzle source, build the playable // game from it now and pull the canonical Game record, Moves and @@ -166,10 +197,18 @@ final class CloudService { } } else { syncMonitor.note("Share accepted — fetching shared game") - _ = try await syncEngine.fetchAcceptedSharedGameDirect( - gameID: sharedGameID, - zoneID: sharedZoneID - ) + do { + _ = try await syncEngine.fetchAcceptedSharedGameDirect( + gameID: sharedGameID, + zoneID: sharedZoneID + ) + } catch { + guard Self.isRetryableJoinError(error) else { throw error } + syncMonitor.note( + "acceptShare: initial shared-game fetch will retry — " + + Self.describeCloudError(error) + ) + } } } else { syncMonitor.note("Share accepted — discovering shared zone") @@ -182,7 +221,7 @@ final class CloudService { // of this call, so waiting here keeps the user on the joining screen // through a slow sync rather than dropping them back at the Game // List with an unopened game. - let joinedGameID = await waitForPlayablePuzzle( + let joinedGameID = try await waitForPlayablePuzzle( gameID: sharedGameID, zoneID: sharedZoneID ) @@ -204,6 +243,12 @@ final class CloudService { return joinedGameID == nil ? .pendingSync : .opened } catch { syncMonitor.recordError("acceptShare", error) + if shareWasAccepted, !(error is AcceptedShareError) { + throw AcceptedShareError( + kind: Self.joinFailureKind(for: error), + underlyingError: error + ) + } throw error } } @@ -226,7 +271,10 @@ final class CloudService { /// check in the common case. Returns nil on timeout, or when the join /// `Task` is cancelled (the user tapped Cancel) — the caller then doesn't /// navigate. - private func waitForPlayablePuzzle(gameID: UUID?, zoneID: CKRecordZone.ID?) async -> UUID? { + private func waitForPlayablePuzzle( + gameID: UUID?, + zoneID: CKRecordZone.ID? + ) async throws -> UUID? { guard let gameID else { return nil } if store.joinedSharedGameIDs().contains(gameID) { return gameID } let deadline = Date().addingTimeInterval(Self.joinSyncTimeout) @@ -235,6 +283,7 @@ final class CloudService { // interval. In the common case the asset commits within a poll or two // and we return without a single redundant three-query read. var nextRefetch = Date().addingTimeInterval(Self.joinSyncRefetchInterval) + var lastRetryableError: Error? while Date() < deadline { if store.joinedSharedGameIDs().contains(gameID) { return gameID } @@ -243,15 +292,33 @@ final class CloudService { // The gate only reads `puzzleSource`, so the backstop needs // just the Game record — the initial accept fetch already // pulled Moves/Players, and the grid re-fetches them on open. - _ = try? await syncEngine.fetchAcceptedSharedGameDirect( - gameID: gameID, - zoneID: zoneID, - onlyGame: true - ) + do { + _ = try await syncEngine.fetchAcceptedSharedGameDirect( + gameID: gameID, + zoneID: zoneID, + onlyGame: true + ) + lastRetryableError = nil + } catch { + guard Self.isRetryableJoinError(error) else { throw error } + lastRetryableError = error + } } else { - _ = try? await syncEngine.fetchGameDirect(scope: .shared, gameID: gameID) + do { + _ = try await syncEngine.fetchGameDirect( + scope: .shared, + gameID: gameID + ) + lastRetryableError = nil + } catch { + guard Self.isRetryableJoinError(error) else { throw error } + lastRetryableError = error + } } - nextRefetch = Date().addingTimeInterval(Self.joinSyncRefetchInterval) + let retryAfter = lastRetryableError.flatMap(Self.retryAfter) + nextRefetch = Date().addingTimeInterval( + max(Self.joinSyncRefetchInterval, retryAfter ?? 0) + ) if store.joinedSharedGameIDs().contains(gameID) { return gameID } } @@ -265,9 +332,63 @@ final class CloudService { "acceptShare: puzzle not playable within \(Int(Self.joinSyncTimeout))s " + "for \(gameID.uuidString)" ) + if let lastRetryableError { throw lastRetryableError } + return nil + } + + nonisolated static func joinFailureKind(for error: Error) -> AcceptedShareError.Kind { + let code = cloudErrorCode(error) + if code == .unknownItem || code == .zoneNotFound || code == .userDeletedZone { + return .removed + } + return isRetryableJoinError(error) ? .temporarilyUnavailable : .unavailable + } + + nonisolated static func isRetryableJoinError(_ error: Error) -> Bool { + switch cloudErrorCode(error) { + case .internalError, + .networkUnavailable, + .networkFailure, + .serviceUnavailable, + .requestRateLimited, + .zoneBusy, + .operationCancelled, + .serverResponseLost, + .accountTemporarilyUnavailable: + return true + default: + return false + } + } + + nonisolated static func retryAfter(_ error: Error) -> TimeInterval? { + let value = (unwrappedCloudError(error) as NSError) + .userInfo[CKErrorRetryAfterKey] + if let number = value as? NSNumber { return max(0, number.doubleValue) } + if let interval = value as? TimeInterval { return max(0, interval) } return nil } + nonisolated static func cloudErrorCode(_ error: Error) -> CKError.Code? { + let nsError = unwrappedCloudError(error) as NSError + guard nsError.domain == CKErrorDomain else { return nil } + return CKError.Code(rawValue: nsError.code) + } + + nonisolated private static func unwrappedCloudError(_ error: Error) -> Error { + if let accepted = error as? AcceptedShareError { + return unwrappedCloudError(accepted.underlyingError) + } + return error + } + + nonisolated private static func describeCloudError(_ error: Error) -> String { + let nsError = unwrappedCloudError(error) as NSError + let retry = retryAfter(error).map { " retryAfter=\($0)s" } ?? "" + return "domain=\(nsError.domain) code=\(nsError.code)\(retry) " + + nsError.localizedDescription + } + func resetAllData() async throws { await syncEngine.resetSyncState() diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -108,7 +108,7 @@ final class InviteCoordinator { // never receive the invite Ping that tells them about the seat. try await friendController.ensureOutboxReady(friendAuthorID: friendAuthorID) - let url = try await shareController.addFriendParticipant( + let invitationShare = try await shareController.addFriendParticipant( toGameID: gameID, userRecordName: friendAuthorID ) @@ -118,20 +118,55 @@ final class InviteCoordinator { // before the canonical Game record necessarily arrives. let notification = store.ensurePushCredentials(for: gameID) .flatMap { try? $0.encoded() } - try await friendController.sendInvite( - toFriendAuthorID: friendAuthorID, - gameID: gameID, - gameTitle: title, - inviterAuthorID: localAuthorID, - inviterName: preferences.name, - gameShareURL: url, - gridSilhouette: silhouette, - puzzleSource: puzzleSource, - notification: notification - ) + do { + try await friendController.sendInvite( + toFriendAuthorID: friendAuthorID, + gameID: gameID, + gameTitle: title, + inviterAuthorID: localAuthorID, + inviterName: preferences.name, + gameShareURL: invitationShare.url, + gridSilhouette: silhouette, + puzzleSource: puzzleSource, + notification: notification, + rollbackParticipantOnFailure: invitationShare.participantWasAdded + ) + } catch { + let pingDeliveryFailed: Bool + if let pingError = error as? SyncEngine.PingOutboxError, + case .deliveryFailed = pingError { + pingDeliveryFailed = true + } else { + pingDeliveryFailed = false + } + if invitationShare.participantWasAdded, + !(error is CancellationError), + !pingDeliveryFailed { + try? await shareController.removeFriendParticipant( + fromGameID: gameID, + userRecordName: friendAuthorID + ) + } + throw error + } await publishInvitePush(friendAuthorID, gameID, title, preferences.name) } + /// Reverses the CKShare mutation attached to a Ping that CloudKit + /// permanently rejected. The delivery payload records whether this + /// particular invitation created the seat, so re-inviting an existing + /// participant can never remove their earlier access. + func rollbackUndeliveredInvite(gameID: UUID, friendAuthorID: String) async { + do { + try await shareController.removeFriendParticipant( + fromGameID: gameID, + userRecordName: friendAuthorID + ) + } catch { + syncMonitor.recordError("rollback undelivered invite", error) + } + } + /// For each collaborative game with newly-known remote authors, asks the /// `FriendController` to bootstrap a friendship. `establish` is symmetric /// (both sides run it) and a no-op once this side's inbox is published (a @@ -360,7 +395,18 @@ final class InviteCoordinator { prefetchedPuzzleSource: prefetchedPuzzleSource, prefetchedNotification: prefetchedNotification ) - } catch let error as CKError where error.code == .unknownItem { + } catch let error as AcceptedShareError where error.kind == .removed { + do { + try await deleteInviteAndPing(pingRecordName: pingRecordName) + syncMonitor.note("accept invite: removed unavailable invite \(pingRecordName)") + } catch { + syncMonitor.note( + "accept invite: unavailable-invite cleanup failed for " + + "\(pingRecordName) — \(error)" + ) + } + throw error + } catch let error as CKError where error.code == .unknownItem || error.code == .zoneNotFound { // Stale share: the row needs to go away too, but the next // `applyInvitePings` will GC it if this cleanup itself fails. // The user-visible signal here is `.unavailable`, so don't let a diff --git a/Crossmate/Services/InviteDeliveryStore.swift b/Crossmate/Services/InviteDeliveryStore.swift @@ -17,14 +17,19 @@ final class InviteDelivery { let gameID: UUID let friendAuthorID: String private(set) var phase: InviteDeliveryPhase = .idle + private(set) var failure: InviteDeliveryFailure? init(gameID: UUID, friendAuthorID: String) { self.gameID = gameID self.friendAuthorID = friendAuthorID } - fileprivate func setPhase(_ phase: InviteDeliveryPhase) { + fileprivate func setPhase( + _ phase: InviteDeliveryPhase, + failure: InviteDeliveryFailure? = nil + ) { self.phase = phase + self.failure = failure } } @@ -40,6 +45,28 @@ final class InviteDeliveryStore { private var deliveries: [Key: InviteDelivery] = [:] private var keyByRecordName: [String: Key] = [:] + private var activePresentationCounts: [UUID: Int] = [:] + + static func failureAnnouncementID(gameID: UUID, friendAuthorID: String) -> String { + "invite-delivery-failed-\(gameID.uuidString)-\(friendAuthorID)" + } + + func beginPresentation(for gameID: UUID) { + activePresentationCounts[gameID, default: 0] += 1 + } + + func endPresentation(for gameID: UUID) { + let next = max(0, (activePresentationCounts[gameID] ?? 0) - 1) + if next == 0 { + activePresentationCounts.removeValue(forKey: gameID) + } else { + activePresentationCounts[gameID] = next + } + } + + func hasActivePresentation(for gameID: UUID) -> Bool { + (activePresentationCounts[gameID] ?? 0) > 0 + } func delivery(gameID: UUID, friendAuthorID: String) -> InviteDelivery { let key = Key(gameID: gameID, friendAuthorID: friendAuthorID) @@ -65,10 +92,15 @@ final class InviteDeliveryStore { .setPhase(.sent) } - func markFailed(recordName: String?, gameID: UUID, friendAuthorID: String) { + func markFailed( + recordName: String?, + gameID: UUID, + friendAuthorID: String, + failure: InviteDeliveryFailure = .other + ) { if let recordName { keyByRecordName.removeValue(forKey: recordName) } let delivery = delivery(gameID: gameID, friendAuthorID: friendAuthorID) guard delivery.phase != .sent else { return } - delivery.setPhase(.failed) + delivery.setPhase(.failed, failure: failure) } } diff --git a/Crossmate/Sync/CloudDiagnostics.swift b/Crossmate/Sync/CloudDiagnostics.swift @@ -73,6 +73,7 @@ extension SyncEngine { let pendingChangesCount: Int let privatePendingCount: Int let sharedPendingCount: Int + let pendingInvitationCount: Int } /// Record names of pending `.saveRecord` changes queued on the given @@ -110,7 +111,8 @@ extension SyncEngine { engineRunning: running, pendingChangesCount: privateCount + sharedCount, privatePendingCount: privateCount, - sharedPendingCount: sharedCount + sharedPendingCount: sharedCount, + pendingInvitationCount: pendingInvitationPingCount() ) } diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -808,13 +808,13 @@ extension SyncEngine { ) (gameResults, moves, players) = try await (gameResultsTask, movesTask, playersTask) } catch { - if isZoneNotFoundError(error) { return false } throw error } - guard case .success(let game)? = gameResults[gameRecordID] else { - return false - } + guard let game = try Self.acceptedGameRecord( + from: gameResults, + recordID: gameRecordID + ) else { return false } let records = moves + players + [game] // Only advance the checkpoint when this pass actually read the zone's @@ -839,6 +839,19 @@ extension SyncEngine { return true } + /// Extracts the requested Game record without erasing a per-record + /// CloudKit failure. `CKDatabase.records(for:)` can complete its operation + /// successfully while returning an error for this one ID; treating that as + /// an absent record turns quota, permission, and rate-limit failures into a + /// misleading "still syncing" timeout. + nonisolated static func acceptedGameRecord( + from results: [CKRecord.ID: Result<CKRecord, Error>], + recordID: CKRecord.ID + ) throws -> CKRecord? { + guard let result = results[recordID] else { return nil } + return try result.get() + } + /// Hydrates a completed game selected by the Game List's metadata pager. /// Unlike `fetchGameDirect`, this accepts the server zone identity because /// the game may not have a local Core Data row yet. diff --git a/Crossmate/Sync/DiagnosticsReport.swift b/Crossmate/Sync/DiagnosticsReport.swift @@ -24,6 +24,10 @@ enum DiagnosticsReport { lines.append("Account Status: \(accountStatusText(syncMonitor))") lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))") lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")") + lines.append( + "Pending Invitations: " + + (syncMonitor.snapshot.map { String($0.pendingInvitationCount) } ?? "Unknown") + ) lines.append("Last Success: \(syncMonitor.lastSuccessAt.map { utcFormatter.string(from: $0) } ?? "None")") lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")") lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")") diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -520,7 +520,8 @@ final class FriendController { gameShareURL: URL, gridSilhouette: String? = nil, puzzleSource: String? = nil, - notification: String? = nil + notification: String? = nil, + rollbackParticipantOnFailure: Bool = false ) async throws { let ctx = persistence.viewContext let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") @@ -552,7 +553,9 @@ final class FriendController { addressee: friendAuthorID, friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), friendZoneScope: .shared, - payload: encoded + payload: encoded, + rollbackParticipantOnFailure: rollbackParticipantOnFailure, + waitForServerConfirmation: true ) } diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -7,6 +7,14 @@ import Foundation /// existing shares on re-present, and letting participants leave a shared game. @MainActor final class ShareController { + struct FriendInvitationShare { + let url: URL + /// True only when this invitation added the participant. A later Ping + /// failure may remove that participant without disturbing a seat that + /// belonged to an earlier, successfully delivered invitation. + let participantWasAdded: Bool + } + private static let zoneWideShareRecordName = CKRecordNameZoneWideShare static let maximumPeoplePerPuzzle = 3 private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 } @@ -177,7 +185,7 @@ final class ShareController { func addFriendParticipant( toGameID gameID: UUID, userRecordName: String - ) async throws -> URL { + ) async throws -> FriendInvitationShare { syncMonitor?.recordStart("invite friend to game") do { let share = try await prepareShareRecord( @@ -185,6 +193,10 @@ final class ShareController { publicPermission: .none, reconfigureExistingPublicPermission: false ) + let participantWasKnown = sessionInvitedAuthorIDs[gameID]?.contains( + userRecordName + ) == true || Self.inviteeAuthorIDs(in: share).contains(userRecordName) + noteShareState("invite prepared", share: share, gameID: gameID) // Re-assert every invitee added this session, not just the new // one. CloudKit reads are not read-after-write consistent, so the // share fetched above can omit a participant added moments earlier @@ -199,6 +211,7 @@ final class ShareController { try await addParticipantIfNeeded(authorID, to: share) } revokePublicAccessIfFull(of: share) + noteShareState("invite saving", share: share, gameID: gameID) let saved: CKShare do { saved = try await saveShareForLink(share, for: gameID) @@ -210,9 +223,13 @@ final class ShareController { ) } sessionInvitedAuthorIDs[gameID] = intended + noteShareState("invite saved", share: saved, gameID: gameID) let url = try shareURL(from: saved) syncMonitor?.recordSuccess("invite friend to game") - return url + return FriendInvitationShare( + url: url, + participantWasAdded: !participantWasKnown + ) } catch { syncMonitor?.recordError("invite friend to game", error) throw error @@ -252,6 +269,7 @@ final class ShareController { syncMonitor?.recordSuccess("free declined seat") return } + noteShareState("participant rollback saving", share: share, gameID: gameID) var removedAccepted = removed.acceptanceStatus == .accepted do { _ = try await saveShareForLink(share, for: gameID) @@ -278,6 +296,7 @@ final class ShareController { if removedAccepted { onParticipantRemoved?(gameID) } + noteShareState("participant rollback saved", share: share, gameID: gameID) syncMonitor?.recordSuccess("free declined seat") } catch { syncMonitor?.recordError("free declined seat", error) @@ -298,6 +317,27 @@ final class ShareController { share.addParticipant(participant) } + /// Leaves enough CKShare structure in the scrubbed diagnostics log to + /// distinguish a participant mutation, an ownership/scope mismatch, and a + /// server-side rejection without exposing full CloudKit identities. + private func noteShareState(_ phase: String, share: CKShare, gameID: UUID) { + let participants = share.participants.map { participant in + let recordName = participant.userIdentity.userRecordID?.recordName ?? "anonymous" + return "\(recordName){role=\(participant.role.rawValue)," + + "status=\(participant.acceptanceStatus.rawValue)," + + "permission=\(participant.permission.rawValue)}" + }.joined(separator: ",") + syncMonitor?.note( + "share \(phase): game=\(gameID.uuidString) " + + "record=\(share.recordID.recordName) " + + "zone=\(share.recordID.zoneID.zoneName) " + + "owner=\(share.recordID.zoneID.ownerName) " + + "publicPermission=\(share.publicPermission.rawValue) " + + "url=\(share.url == nil ? "missing" : "present") " + + "participants=[\(participants)]" + ) + } + /// Removes the non-owner participant matching `userRecordName` from `share`, /// returning the removed participant (its `acceptanceStatus` tells the /// caller whether the person ever had zone access). Idempotent: a diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -15,11 +15,40 @@ enum PingDeliveryState: Sendable { case failed } +enum InviteDeliveryFailure: Equatable, Sendable { + case quotaExceeded + case other + + var title: LocalizedStringResource { + "Invitation Not Sent" + } + + var body: LocalizedStringResource { + switch self { + case .quotaExceeded: + "The invitation was not received. This could be because your friend's iCloud storage is full." + case .other: + "The invitation could not be delivered. Try again." + } + } + + init(error: Error) { + if let pingError = error as? SyncEngine.PingOutboxError, + pingError.isQuotaExceeded { + self = .quotaExceeded + } else { + self = .other + } + } +} + struct PingDeliveryUpdate: Sendable { let recordName: String let gameID: UUID let addressee: String let state: PingDeliveryState + let rollbackParticipantOnFailure: Bool + let failure: InviteDeliveryFailure? } extension EnvironmentValues { @@ -61,8 +90,28 @@ extension Notification.Name { /// replay them onto the `CellEntity` cache. /// - Notify the main actor so the in-memory `Game` stays current. actor SyncEngine { - enum PingOutboxError: Error { + enum PingOutboxError: Error, Equatable, LocalizedError { case syncEngineUnavailable + case deliveryFailed(code: Int?) + + var errorDescription: String? { + switch self { + case .syncEngineUnavailable: + return "The invitation could not be queued for syncing." + case .deliveryFailed(let code) + where code == CKError.quotaExceeded.rawValue: + return String(localized: InviteDeliveryFailure.quotaExceeded.body) + case .deliveryFailed: + return String(localized: InviteDeliveryFailure.other.body) + } + } + + var isQuotaExceeded: Bool { + if case .deliveryFailed(let code) = self { + return code == CKError.quotaExceeded.rawValue + } + return false + } } let container: CKContainer @@ -97,6 +146,10 @@ actor SyncEngine { /// body supplied by its record provider, so the outbox is what makes a /// queued invite reconstructable after process termination. private var pendingPings: [String: PingPayload] = [:] + private var pingDeliveryWaiters: [ + String: CheckedContinuation<Void, Error> + ] = [:] + private var scheduledPingRetries: Set<String> = [] private lazy var pendingPingContext: NSManagedObjectContext = { let ctx = persistence.container.newBackgroundContext() @@ -161,6 +214,10 @@ actor SyncEngine { let zoneName: String let zoneOwnerName: String let databaseScope: DatabaseScope + /// Present only on game invitations whose CKShare seat was created by + /// this Ping. Optional so durable outbox rows written by older builds + /// continue to decode. + let rollbackParticipantOnFailure: Bool? var recordZoneID: CKRecordZone.ID { CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName) @@ -748,10 +805,44 @@ actor SyncEngine { } private func confirmPendingPing(recordName: String) async { + scheduledPingRetries.remove(recordName) if let ping = pendingPings[recordName] { await publishPingDeliveryUpdate(.sent, recordName: recordName, ping: ping) } await removePendingPing(recordName: recordName) + pingDeliveryWaiters.removeValue(forKey: recordName)?.resume() + } + + private func failPendingPing(recordName: String, error: NSError? = nil) async { + scheduledPingRetries.remove(recordName) + if let ping = pendingPings[recordName] { + let failure: InviteDeliveryFailure = error?.code + == CKError.quotaExceeded.rawValue ? .quotaExceeded : .other + await publishPingDeliveryUpdate( + .failed, + recordName: recordName, + ping: ping, + failure: failure + ) + } + await removePendingPing(recordName: recordName) + pingDeliveryWaiters.removeValue(forKey: recordName)? + .resume(throwing: PingOutboxError.deliveryFailed(code: error?.code)) + } + + private func waitForPingDelivery(recordName: String) async throws { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pingDeliveryWaiters[recordName] = continuation + } + } onCancel: { + Task { await self.cancelPingDeliveryWait(recordName: recordName) } + } + } + + private func cancelPingDeliveryWait(recordName: String) { + pingDeliveryWaiters.removeValue(forKey: recordName)? + .resume(throwing: CancellationError()) } private func restorePendingPings() async { @@ -781,7 +872,8 @@ actor SyncEngine { private func publishPingDeliveryUpdate( _ state: PingDeliveryState, recordName: String, - ping: PingPayload + ping: PingPayload, + failure: InviteDeliveryFailure? = nil ) async { guard ping.kind == .invite, let addressee = ping.addressee, @@ -792,7 +884,9 @@ actor SyncEngine { recordName: recordName, gameID: ping.gameID, addressee: addressee, - state: state + state: state, + rollbackParticipantOnFailure: ping.rollbackParticipantOnFailure == true, + failure: failure )) } @@ -804,6 +898,10 @@ actor SyncEngine { } } + func pendingInvitationPingCount() -> Int { + pendingPings.values.count { $0.kind == PingKind.invite } + } + /// Registers a Ping record as a pending send. Current clients only write /// bootstrap kinds (`.friend` / `.invite` / `.decline`) — the /// user-facing play events go through the push worker. Sender-only @@ -866,7 +964,8 @@ actor SyncEngine { addressee: addressee, zoneName: zoneAndTitle.info.zoneID.zoneName, zoneOwnerName: zoneAndTitle.info.zoneID.ownerName, - databaseScope: zoneAndTitle.info.scope + databaseScope: zoneAndTitle.info.scope, + rollbackParticipantOnFailure: nil ) do { try await storePendingPing(ping, recordName: recordName) @@ -1112,7 +1211,9 @@ actor SyncEngine { addressee: String, friendZoneID: CKRecordZone.ID, friendZoneScope: DatabaseScope, - payload: String? = nil + payload: String? = nil, + rollbackParticipantOnFailure: Bool = false, + waitForServerConfirmation: Bool = false ) async throws { let engine = friendZoneScope == .shared ? sharedEngine : privateEngine guard let engine else { @@ -1138,12 +1239,16 @@ actor SyncEngine { addressee: addressee, zoneName: friendZoneID.zoneName, zoneOwnerName: friendZoneID.ownerName, - databaseScope: friendZoneScope + databaseScope: friendZoneScope, + rollbackParticipantOnFailure: rollbackParticipantOnFailure ) try await storePendingPing(ping, recordName: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: friendZoneID) engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) sendChangesDetached(on: engine) + if waitForServerConfirmation { + try await waitForPingDelivery(recordName: recordName) + } } /// Registers a durable `Decision` record into the account zone so the fact @@ -1320,10 +1425,7 @@ actor SyncEngine { guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return } let engine = info.scope == .shared ? sharedEngine : privateEngine guard let engine else { return } - if let ping = pendingPings[recordName] { - await publishPingDeliveryUpdate(.failed, recordName: recordName, ping: ping) - } - await removePendingPing(recordName: recordName) + await failPendingPing(recordName: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: info.zoneID) engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) sendChangesDetached(on: engine) @@ -1336,10 +1438,7 @@ actor SyncEngine { func deletePing(recordName: String, zoneID: CKRecordZone.ID, databaseScope: DatabaseScope) async { let engine = databaseScope == .shared ? sharedEngine : privateEngine guard let engine else { return } - if let ping = pendingPings[recordName] { - await publishPingDeliveryUpdate(.failed, recordName: recordName, ping: ping) - } - await removePendingPing(recordName: recordName) + await failPendingPing(recordName: recordName) let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) engine.state.add(pendingRecordZoneChanges: [.deleteRecord(recordID)]) sendChangesDetached(on: engine) @@ -2054,6 +2153,79 @@ actor SyncEngine { return "Unknown" } + /// A pending Ping remains durable for conditions CloudKit documents as + /// retryable. These errors instead mean the particular write cannot + /// succeed without a new user action or a corrected request, so leaving it + /// queued would show a clock forever and retain an unusable CKShare seat. + nonisolated static func isTerminalPingSaveError(_ error: NSError) -> Bool { + guard error.domain == CKErrorDomain, + let code = CKError.Code(rawValue: error.code) + else { return false } + switch code { + case .badContainer, + .missingEntitlement, + .notAuthenticated, + .permissionFailure, + .unknownItem, + .invalidArguments, + .serverRejectedRequest, + .incompatibleVersion, + .constraintViolation, + .badDatabase, + .quotaExceeded, + .zoneNotFound, + .limitExceeded, + .userDeletedZone, + .tooManyParticipants, + .referenceViolation, + .managedAccountRestricted: + return true + default: + return false + } + } + + nonisolated static func pingSaveRetryDelay(_ error: NSError) -> TimeInterval? { + guard error.domain == CKErrorDomain, + let code = CKError.Code(rawValue: error.code) + else { return nil } + switch code { + case .internalError, + .networkUnavailable, + .networkFailure, + .serviceUnavailable, + .requestRateLimited, + .zoneBusy, + .operationCancelled, + .serverResponseLost, + .accountTemporarilyUnavailable: + let serverDelay = (error.userInfo[CKErrorRetryAfterKey] as? NSNumber)? + .doubleValue + return max(3, serverDelay ?? 0) + default: + return nil + } + } + + private func schedulePendingPingRetry( + recordID: CKRecord.ID, + isPrivate: Bool, + after delay: TimeInterval + ) { + let recordName = recordID.recordName + guard pendingPings[recordName] != nil, + scheduledPingRetries.insert(recordName).inserted + else { return } + Task { + try? await Task.sleep(for: .seconds(delay)) + scheduledPingRetries.remove(recordName) + guard !Task.isCancelled, pendingPings[recordName] != nil else { return } + let engine = isPrivate ? privateEngine : sharedEngine + engine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) + if let engine { sendChangesDetached(on: engine) } + } + } + private func handleSentRecordZoneChanges( _ event: CKSyncEngine.Event.SentRecordZoneChanges, isPrivate: Bool @@ -2294,6 +2466,36 @@ actor SyncEngine { if !orphanedZones.isEmpty { await applyZoneOrphaning(orphanedZones, isPrivate: isPrivate, source: "send") } + let terminalPingFailures = event.failedRecordSaves.filter { failure in + failure.record.recordID.recordName.hasPrefix("ping-") + && Self.isTerminalPingSaveError(failure.error as NSError) + } + if !terminalPingFailures.isEmpty { + let engine = isPrivate ? privateEngine : sharedEngine + let changes = terminalPingFailures.map { + CKSyncEngine.PendingRecordZoneChange.saveRecord($0.record.recordID) + } + engine?.state.remove(pendingRecordZoneChanges: changes) + for failure in terminalPingFailures { + let recordName = failure.record.recordID.recordName + guard pendingPings[recordName] != nil else { continue } + await failPendingPing( + recordName: recordName, + error: failure.error as NSError + ) + } + } + for failure in event.failedRecordSaves { + let recordID = failure.record.recordID + guard recordID.recordName.hasPrefix("ping-"), + let delay = Self.pingSaveRetryDelay(failure.error as NSError) + else { continue } + schedulePendingPingRetry( + recordID: recordID, + isPrivate: isPrivate, + after: delay + ) + } // Settle/retry against the engine this sent-event belongs to: account- // zone decisions ride the private engine, but a name Decision in a // joined friend zone rides the shared one. @@ -2491,10 +2693,7 @@ actor SyncEngine { zones.contains(ping.recordZoneID) ? name : nil } for name in orphanedPingNames { - if let ping = pendingPings[name] { - await publishPingDeliveryUpdate(.failed, recordName: name, ping: ping) - } - await removePendingPing(recordName: name) + await failPendingPing(recordName: name) } let ctx = persistence.container.newBackgroundContext() diff --git a/Crossmate/Views/Friends/FriendPickerView.swift b/Crossmate/Views/Friends/FriendPickerView.swift @@ -21,7 +21,8 @@ struct FriendPickerView: View { @State private var invitingAuthorID: String? @State private var invitedAuthorIDs: Set<String> @State private var isInviteLimitReached: Bool - @State private var errorMessage: String? + @State private var failedInviteAuthorID: String? + @State private var inviteFailure: InviteDeliveryFailure? init(gameID: UUID, shareController: ShareController, isInviteLimitReached: Bool = false) { self.gameID = gameID @@ -37,6 +38,18 @@ struct FriendPickerView: View { var body: some View { List { + if let failedInviteAuthorID, let inviteFailure { + Section { + InvitationFailureCallout( + failure: inviteFailure, + isRetrying: invitingAuthorID != nil, + retry: { + Task { await invite(failedInviteAuthorID) } + } + ) + } + } + Section { if friends.isEmpty { Text("No Prior Crossmates") @@ -55,18 +68,15 @@ struct FriendPickerView: View { .textCase(nil) .padding(.bottom, 8) } - - if let errorMessage { - Section("Error") { - Text(errorMessage) - .font(.caption.monospaced()) - .foregroundStyle(.red) - .textSelection(.enabled) - } - } } .navigationTitle("Invite a Crossmate") .navigationBarTitleDisplayMode(.inline) + .onAppear { + appActions?.beginInvitationPresentation(gameID: gameID) + } + .onDisappear { + appActions?.endInvitationPresentation(gameID: gameID) + } .task { // Reflect friends already on the share so re-opening the picker // shows their checkmark instead of an un-invited glyph. @@ -81,6 +91,10 @@ struct FriendPickerView: View { private func friendRow(for friend: FriendEntity) -> some View { let authorID = friend.authorID ?? "" let invited = invitedAuthorIDs.contains(authorID) + let deliveryFailed = appActions?.inviteDelivery( + gameID: gameID, + friendAuthorID: authorID + ).phase == .failed Button { Task { await invite(authorID) } } label: { @@ -94,7 +108,12 @@ struct FriendPickerView: View { Spacer() } } - .disabled(authorID.isEmpty || invitingAuthorID != nil || invited || (isInviteLimitReached && !invited)) + .disabled( + authorID.isEmpty + || invitingAuthorID != nil + || (invited && !deliveryFailed) + || (isInviteLimitReached && !invited && !deliveryFailed) + ) } /// Maps durable delivery ahead of the task-local sending state so the UI @@ -120,7 +139,8 @@ struct FriendPickerView: View { private func invite(_ authorID: String) async { guard !authorID.isEmpty, let appActions else { return } withAnimation(.snappy) { invitingAuthorID = authorID } - errorMessage = nil + failedInviteAuthorID = nil + inviteFailure = nil defer { withAnimation(.snappy) { invitingAuthorID = nil } } do { try await appActions.inviteFriend(gameID: gameID, friendAuthorID: authorID) @@ -129,15 +149,18 @@ struct FriendPickerView: View { isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 } } catch { - appActions.markInviteFailed(gameID: gameID, friendAuthorID: authorID) + appActions.markInviteFailed( + gameID: gameID, + friendAuthorID: authorID, + error: error + ) if case ShareController.ShareError.collaborationLimitReached = error { withAnimation(.snappy) { isInviteLimitReached = true } } - errorMessage = describe(error) + withAnimation(.snappy) { + failedInviteAuthorID = authorID + inviteFailure = InviteDeliveryFailure(error: error) + } } } - - private func describe(_ error: Error) -> String { - (error as? LocalizedError)?.errorDescription ?? String(describing: error) - } } diff --git a/Crossmate/Views/Friends/InvitationFailureCallout.swift b/Crossmate/Views/Friends/InvitationFailureCallout.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct InvitationFailureCallout: View { + let failure: InviteDeliveryFailure + let isRetrying: Bool + let retry: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "icloud.slash.fill") + .font(.title2) + .foregroundStyle(.red) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 8) { + Text(failure.title) + .font(.headline) + + Text(failure.body) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button("Try Again", action: retry) + .buttonStyle(.borderedProminent) + .disabled(isRetrying) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 4) + } +} diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -366,6 +366,14 @@ struct GameListView: View { do { try await appActions.inviteFriend(gameID: gameID, friendAuthorID: target.authorID) } catch { + announcements.dismiss( + id: InviteDeliveryStore.failureAnnouncementID( + gameID: gameID, + friendAuthorID: target.authorID + ) + ) + let quotaExceeded = (error as? SyncEngine.PingOutboxError)? + .isQuotaExceeded == true eventLog.note( "new game friend invite failed game=\(gameID.uuidString) friend=\(target.authorID): \(error)", level: "error" @@ -374,8 +382,12 @@ struct GameListView: View { id: Self.newGameInviteErrorID, scope: .game(gameID), severity: .error, - title: "Inviting Failed", - body: "\(target.displayName) could not be invited. Try again from the Share menu.", + title: quotaExceeded + ? String(localized: InviteDeliveryFailure.quotaExceeded.title) + : "Inviting Failed", + body: quotaExceeded + ? String(localized: InviteDeliveryFailure.quotaExceeded.body) + : "\(target.displayName) could not be invited. Try again from the Share menu.", dismissal: .manual )) } @@ -854,12 +866,17 @@ struct GameListView: View { try await appActions.acceptInvite(shareURL: url, pingRecordName: ping) } } catch { + let acceptedError = error as? AcceptedShareError announcements.post(Announcement( id: Self.inviteErrorID, scope: .global, severity: .error, - title: "Accepting Failed", - body: error.localizedDescription, + title: acceptedError?.isQuotaExceeded == true + ? CloudFailureCopy.quotaJoinTitle + : (acceptedError == nil ? "Accepting Failed" : "Puzzle Unavailable"), + body: acceptedError?.isQuotaExceeded == true + ? CloudFailureCopy.quotaJoinBody + : error.localizedDescription, dismissal: .manual )) } diff --git a/Crossmate/Views/GameList/GameShareItem.swift b/Crossmate/Views/GameList/GameShareItem.swift @@ -30,6 +30,8 @@ struct GameShareSheet: View { @State private var isInviteLimitReached = false @State private var mailReport: MailReport? @State private var reportFallbackNote: String? + @State private var failedInviteAuthorID: String? + @State private var inviteFailure: InviteDeliveryFailure? private static let supportEmail = "[email protected]" private static let visibleFriendLimit = 12 @@ -105,6 +107,18 @@ struct GameShareSheet: View { .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) + if let failedInviteAuthorID, let inviteFailure { + Section { + InvitationFailureCallout( + failure: inviteFailure, + isRetrying: invitingAuthorID != nil, + retry: { + Task { await invite(failedInviteAuthorID) } + } + ) + } + } + Section { if isDirectInviteMode { Label("Link Sharing Unavailable", systemImage: "link.badge.plus") @@ -198,6 +212,11 @@ struct GameShareSheet: View { if let shareError { Section { + if let errorTitle = shareError.title { + Text(errorTitle) + .font(.headline) + .foregroundStyle(.red) + } Text(shareError.message) .font(.footnote) .foregroundStyle(.red) @@ -223,6 +242,12 @@ struct GameShareSheet: View { } .navigationTitle("Invite Players") .navigationBarTitleDisplayMode(.inline) + .onAppear { + appActions?.beginInvitationPresentation(gameID: gameID) + } + .onDisappear { + appActions?.endInvitationPresentation(gameID: gameID) + } .toolbar { ToolbarItem(placement: .cancellationAction) { Button { @@ -273,6 +298,10 @@ struct GameShareSheet: View { private func friendInviteButton(for friend: FriendEntity) -> some View { let authorID = friend.authorID ?? "" let wasInvited = invitedAuthorIDs.contains(authorID) + let deliveryFailed = appActions?.inviteDelivery( + gameID: gameID, + friendAuthorID: authorID + ).phase == .failed Button { Task { await invite(authorID) } @@ -291,7 +320,13 @@ struct GameShareSheet: View { .frame(maxWidth: .infinity, minHeight: 88) } .buttonStyle(.plain) - .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || isLoadingExistingLink || (isInviteLimitReached && !wasInvited)) + .disabled( + authorID.isEmpty + || invitingAuthorID != nil + || (wasInvited && !deliveryFailed) + || isLoadingExistingLink + || (isInviteLimitReached && !wasInvited && !deliveryFailed) + ) } /// Maps durable delivery ahead of the task-local sending state so the UI @@ -318,6 +353,8 @@ struct GameShareSheet: View { guard !authorID.isEmpty, let appActions else { return } withAnimation(.snappy) { invitingAuthorID = authorID } shareError = nil + failedInviteAuthorID = nil + inviteFailure = nil defer { withAnimation(.snappy) { invitingAuthorID = nil } } do { @@ -327,11 +364,22 @@ struct GameShareSheet: View { isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 } } catch { - appActions.markInviteFailed(gameID: gameID, friendAuthorID: authorID) + appActions.markInviteFailed( + gameID: gameID, + friendAuthorID: authorID, + error: error + ) if case ShareController.ShareError.collaborationLimitReached = error { withAnimation(.snappy) { isInviteLimitReached = true } } - shareError = ShareErrorInfo(error, diagnostic: describe(error)) + if error is ShareController.ShareError { + shareError = ShareErrorInfo(error, diagnostic: describe(error)) + } else { + withAnimation(.snappy) { + failedInviteAuthorID = authorID + inviteFailure = InviteDeliveryFailure(error: error) + } + } } } @@ -373,6 +421,7 @@ struct GameShareSheet: View { /// is shown on screen; the diagnostic is what the Copy/Report buttons carry /// so a report still contains the underlying CloudKit detail. private struct ShareErrorInfo { + let title: String? let message: String let detail: String /// True for unexpected failures (CloudKit, network) worth reporting. @@ -382,9 +431,11 @@ struct GameShareSheet: View { init(_ error: Error, diagnostic: String) { if error is ShareController.ShareError { + title = nil message = error.localizedDescription isReportable = false } else { + title = nil message = "Something went wrong sharing this puzzle. If it keeps happening, report the error so it can be fixed." isReportable = true } diff --git a/Crossmate/Views/Settings/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift @@ -56,6 +56,7 @@ struct DiagnosticsView: View { row("Account Status", accountStatusText) row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning)) row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown") + row("Sharing Health", sharingHealthText) row( "Last Success", syncMonitor.lastSuccessAt.map { TimestampFormatter.string(from: $0, in: .local) } ?? "None" @@ -219,6 +220,17 @@ struct DiagnosticsView: View { private func boolText(_ value: Bool?) -> String { DiagnosticsReport.boolText(value) } + private var sharingHealthText: String { + guard let snapshot = syncMonitor.snapshot else { return "Unknown" } + guard snapshot.engineRunning, snapshot.accountStatus == .available else { + return "Unavailable" + } + let count = snapshot.pendingInvitationCount + return count == 0 + ? "Ready" + : "\(count) invitation\(count == 1 ? "" : "s") waiting for CloudKit" + } + private var versionText: String { DiagnosticsReport.versionText } private var diagnosticDump: DiagnosticsDump { diff --git a/Tests/Unit/CloudJoinFailureTests.swift b/Tests/Unit/CloudJoinFailureTests.swift @@ -0,0 +1,118 @@ +import CloudKit +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Cloud join failure handling") +struct CloudJoinFailureTests { + @Test("Accepted-game extraction propagates a per-record error") + func acceptedGameRecordPropagatesFailure() throws { + let zoneID = CKRecordZone.ID(zoneName: "game-test", ownerName: "_owner") + let recordID = CKRecord.ID(recordName: "game-test", zoneID: zoneID) + let quotaError = NSError( + domain: CKErrorDomain, + code: CKError.quotaExceeded.rawValue + ) + let results: [CKRecord.ID: Result<CKRecord, Error>] = [ + recordID: .failure(quotaError) + ] + + do { + _ = try SyncEngine.acceptedGameRecord(from: results, recordID: recordID) + Issue.record("Expected the record-level CloudKit error") + } catch { + #expect((error as NSError).domain == CKErrorDomain) + #expect((error as NSError).code == CKError.quotaExceeded.rawValue) + } + } + + @Test("Accepted-game extraction returns the requested record") + func acceptedGameRecordReturnsSuccess() throws { + let zoneID = CKRecordZone.ID(zoneName: "game-test", ownerName: "_owner") + let recordID = CKRecord.ID(recordName: "game-test", zoneID: zoneID) + let record = CKRecord(recordType: "Game", recordID: recordID) + + let extracted = try SyncEngine.acceptedGameRecord( + from: [recordID: .success(record)], + recordID: recordID + ) + + #expect(extracted?.recordID == recordID) + } + + @Test("Join retry policy distinguishes transient and terminal errors") + func joinRetryClassification() { + let rateLimit = NSError( + domain: CKErrorDomain, + code: CKError.requestRateLimited.rawValue, + userInfo: [CKErrorRetryAfterKey: NSNumber(value: 12.5)] + ) + let quota = NSError( + domain: CKErrorDomain, + code: CKError.quotaExceeded.rawValue + ) + + #expect(CloudService.isRetryableJoinError(rateLimit)) + #expect(CloudService.retryAfter(rateLimit) == 12.5) + #expect(CloudService.joinFailureKind(for: rateLimit) == .temporarilyUnavailable) + #expect(!CloudService.isRetryableJoinError(quota)) + #expect(CloudService.joinFailureKind(for: quota) == .unavailable) + let acceptedQuota = AcceptedShareError( + kind: .unavailable, + underlyingError: quota + ) + #expect(acceptedQuota.errorDescription == CloudFailureCopy.quotaJoinBody) + } + + @Test("Removed accepted shares retain their distinct outcome") + func removedJoinClassification() { + for code in [CKError.unknownItem, .zoneNotFound, .userDeletedZone] { + let error = NSError(domain: CKErrorDomain, code: code.rawValue) + #expect(CloudService.joinFailureKind(for: error) == .removed) + } + } + + @Test("Only non-retryable Ping errors become terminal") + func terminalPingClassification() { + let terminal = [CKError.quotaExceeded, .permissionFailure, .zoneNotFound] + let retryable = [ + CKError.networkFailure, + .serviceUnavailable, + .requestRateLimited, + .zoneBusy, + .accountTemporarilyUnavailable, + ] + + for code in terminal { + #expect(SyncEngine.isTerminalPingSaveError( + NSError(domain: CKErrorDomain, code: code.rawValue) + )) + } + for code in retryable { + #expect(!SyncEngine.isTerminalPingSaveError( + NSError(domain: CKErrorDomain, code: code.rawValue) + )) + } + + let retryAfter = NSError( + domain: CKErrorDomain, + code: CKError.requestRateLimited.rawValue, + userInfo: [CKErrorRetryAfterKey: NSNumber(value: 12.5)] + ) + #expect(SyncEngine.pingSaveRetryDelay(retryAfter) == 12.5) + #expect(SyncEngine.pingSaveRetryDelay( + NSError(domain: CKErrorDomain, code: CKError.networkFailure.rawValue) + ) == 3) + #expect(SyncEngine.pingSaveRetryDelay( + NSError(domain: CKErrorDomain, code: CKError.quotaExceeded.rawValue) + ) == nil) + let invitationQuota = SyncEngine.PingOutboxError.deliveryFailed( + code: CKError.quotaExceeded.rawValue + ) + #expect( + invitationQuota.errorDescription + == String(localized: InviteDeliveryFailure.quotaExceeded.body) + ) + } +} diff --git a/Tests/Unit/Sync/PendingChangeReapTests.swift b/Tests/Unit/Sync/PendingChangeReapTests.swift @@ -198,9 +198,11 @@ struct PendingChangeReapTests { gameID: gameID, friendAuthorID: friendAuthorID ) + var rollbackFlags: [Bool] = [] #expect(delivery.phase == .idle) await engine.setOnPingDeliveryUpdate { update in + rollbackFlags.append(update.rollbackParticipantOnFailure) switch update.state { case .queued: deliveries.markQueued( @@ -218,7 +220,8 @@ struct PendingChangeReapTests { deliveries.markFailed( recordName: update.recordName, gameID: update.gameID, - friendAuthorID: update.addressee + friendAuthorID: update.addressee, + failure: update.failure ?? .other ) } } @@ -236,12 +239,14 @@ struct PendingChangeReapTests { addressee: friendAuthorID, friendZoneID: zoneID, friendZoneScope: .shared, - payload: #"{"gameShareURL":"https://example.com/share"}"# + payload: #"{"gameShareURL":"https://example.com/share"}"#, + rollbackParticipantOnFailure: true ) let recordName = try #require( await engine.pendingPingRecordNamesForTesting().first ) #expect(delivery.phase == .queued) + #expect(rollbackFlags == [true]) await engine.confirmPendingPingForTesting(recordName: recordName) #expect(delivery.phase == .sent) @@ -255,4 +260,35 @@ struct PendingChangeReapTests { ) #expect(delivery.phase == .sent) } + + @Test("Invitation presentation and failure detail remain scoped to one game") + func invitationPresentationAndFailureState() { + let deliveries = InviteDeliveryStore() + let gameID = UUID() + let otherGameID = UUID() + let friendAuthorID = "_friend" + + deliveries.beginPresentation(for: gameID) + deliveries.beginPresentation(for: gameID) + #expect(deliveries.hasActivePresentation(for: gameID)) + #expect(!deliveries.hasActivePresentation(for: otherGameID)) + + deliveries.endPresentation(for: gameID) + #expect(deliveries.hasActivePresentation(for: gameID)) + deliveries.endPresentation(for: gameID) + #expect(!deliveries.hasActivePresentation(for: gameID)) + + deliveries.markFailed( + recordName: "ping-failed", + gameID: gameID, + friendAuthorID: friendAuthorID, + failure: .quotaExceeded + ) + let delivery = deliveries.delivery( + gameID: gameID, + friendAuthorID: friendAuthorID + ) + #expect(delivery.phase == .failed) + #expect(delivery.failure == .quotaExceeded) + } }