crossmate

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

commit 849ed234799823b43c7de4d34ac3fabc2cd501c0
parent ec02ded59714bbc711837313cdc421f630f84206
Author: Michael Camilleri <[email protected]>
Date:   Sat, 16 May 2026 17:48:33 +0900

Tighten friend invite acceptance and names

Friend invite acceptance was treating CloudKit accept failures as success:
CloudService.acceptShare(metadata:) logged the error and returned, so the
InviteEntity was deleted even though the game was not joined. This commit makes
the accept path log and rethrow so the 'Invited' row stays retryable and the
Game List can surface the existing error alert. OS-delivered share acceptances
still drain their queue after CloudService records the failure, since there is
no direct UI caller to throw to.

Friend rows also now capture an initial displayName when the friendship is
created. The owner side stores the remote PlayerEntity.name discovered during
friendship reconciliation, and the participant side stores the owner name from
the shared PlayerEntity or the bootstrap ping. This leaves future name
staleness for a later pass but avoids every friend appearing as 'Player' in the
invite picker.

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

Diffstat:
MCrossmate/Services/AppServices.swift | 29+++++++++++++++++++++--------
MCrossmate/Services/CloudService.swift | 7++++---
MCrossmate/Sync/FriendController.swift | 33++++++++++++++++++++++++++++++++-
3 files changed, 57 insertions(+), 12 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -929,8 +929,8 @@ final class AppServices { else { return } let ctx = persistence.container.newBackgroundContext() - let candidates: [(gameID: UUID, remoteAuthorID: String)] = ctx.performAndWait { - var result: [(UUID, String)] = [] + let candidates: [(gameID: UUID, remoteAuthorID: String, remoteDisplayName: String?)] = ctx.performAndWait { + var result: [(UUID, String, String?)] = [] for gameID in gameIDs { let gReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") gReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) @@ -942,22 +942,29 @@ final class AppServices { // Identity comes only from Player records — this feature is // deliberately uninterested in Moves (the bootstrap trigger is // the first sighting of a remote Player record). - var authorIDs = Set<String>() + var playersByAuthorID: [String: String?] = [:] let pReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") pReq.predicate = NSPredicate(format: "game == %@", game) for p in (try? ctx.fetch(pReq)) ?? [] { - if let a = p.authorID { authorIDs.insert(a) } + guard let authorID = p.authorID else { continue } + playersByAuthorID[authorID] = p.name + } + playersByAuthorID.removeValue(forKey: localAuthorID) + playersByAuthorID.removeValue(forKey: CKCurrentUserDefaultName) + playersByAuthorID.removeValue(forKey: "") + for (authorID, name) in playersByAuthorID { + result.append((gameID, authorID, name)) } - authorIDs.subtract([localAuthorID, CKCurrentUserDefaultName, ""]) - for a in authorIDs { result.append((gameID, a)) } } return result } - for (gameID, remoteAuthorID) in candidates { + for (gameID, remoteAuthorID, remoteDisplayName) in candidates { await friendController.establishIfOwner( localAuthorID: localAuthorID, remoteAuthorID: remoteAuthorID, + localDisplayName: preferences.name, + remoteDisplayName: remoteDisplayName, viaGameID: gameID ) } @@ -1319,7 +1326,13 @@ final class AppServices { while !pendingShareMetadatas.isEmpty { let metadata = pendingShareMetadatas.removeFirst() - await cloudService.acceptShare(metadata: metadata) + do { + try await cloudService.acceptShare(metadata: metadata) + } 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. + } } } diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -55,10 +55,10 @@ final class CloudService { } ckContainer.add(op) } - await acceptShare(metadata: metadata) + try await acceptShare(metadata: metadata) } - func acceptShare(metadata: CKShare.Metadata) async { + func acceptShare(metadata: CKShare.Metadata) async throws { NotificationCenter.default.post(name: .cloudShareAcceptanceStarted, object: nil) guard metadata.containerIdentifier == ckContainer.containerIdentifier else { @@ -66,7 +66,7 @@ final class CloudService { "acceptShare: container mismatch — metadata=\(metadata.containerIdentifier) " + "expected=\(ckContainer.containerIdentifier ?? "nil")" ) - return + throw CKError(.permissionFailure) } let existingJoinedGameIDs = store.joinedSharedGameIDs() do { @@ -92,6 +92,7 @@ final class CloudService { } } catch { syncMonitor.recordError("acceptShare", error) + throw error } } diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -51,6 +51,8 @@ final class FriendController { func establishIfOwner( localAuthorID: String, remoteAuthorID: String, + localDisplayName: String?, + remoteDisplayName: String?, viaGameID: UUID ) async { guard !remoteAuthorID.isEmpty, @@ -72,6 +74,7 @@ final class FriendController { persistFriend( authorID: remoteAuthorID, + displayName: remoteDisplayName, pairKey: pairKey, zoneName: zoneName, zoneOwnerName: CKCurrentUserDefaultName, @@ -88,7 +91,7 @@ final class FriendController { scope: nil, gameID: viaGameID, authorID: localAuthorID, - playerName: "", + playerName: localDisplayName ?? "", payload: payload.encodedString() ) syncMonitor?.recordSuccess("establish friendship") @@ -116,6 +119,10 @@ final class FriendController { let zoneID = metadata.share.recordID.zoneID persistFriend( authorID: payload.ownerAuthorID, + displayName: displayNameFromPlayer( + gameID: ping.gameID, + authorID: payload.ownerAuthorID + ) ?? ping.playerName, pairKey: payload.pairKey, zoneName: zoneID.zoneName, zoneOwnerName: zoneID.ownerName, @@ -341,6 +348,7 @@ final class FriendController { private func persistFriend( authorID: String, + displayName: String?, pairKey: String, zoneName: String, zoneOwnerName: String, @@ -352,6 +360,9 @@ final class FriendController { req.fetchLimit = 1 let entity = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) entity.authorID = authorID + if let displayName, !displayName.isEmpty { + entity.displayName = displayName + } entity.pairKey = pairKey entity.friendZoneName = zoneName entity.friendZoneOwnerName = zoneOwnerName @@ -359,4 +370,24 @@ final class FriendController { if entity.createdAt == nil { entity.createdAt = Date() } try? ctx.save() } + + private func displayNameFromPlayer(gameID: UUID, authorID: String) -> String? { + let ctx = persistence.viewContext + let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") + gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) + gameReq.fetchLimit = 1 + guard let game = try? ctx.fetch(gameReq).first else { return nil } + + let playerReq = NSFetchRequest<PlayerEntity>(entityName: "PlayerEntity") + playerReq.predicate = NSPredicate( + format: "game == %@ AND authorID == %@", + game, + authorID + ) + playerReq.fetchLimit = 1 + guard let name = try? ctx.fetch(playerReq).first?.name, + !name.isEmpty + else { return nil } + return name + } }