crossmate

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

commit 44d868c84b6eebac26d95a13597fdcf6757088d0
parent 3ec1ed5e075ebfa9715f59a459ef5d6eec8aa520
Author: Michael Camilleri <[email protected]>
Date:   Tue, 30 Jun 2026 08:30:58 +0900

Make blocking a friend reversible via friend mailboxes

Prior to this commit, blocking a friend was permanent. A friendship was
a single shared zone with a deterministically elected owner, and
FriendController.blockAndTeardown revoked the channel outright — the
owner deleted the zone, a participant left the share — so there was
nothing left to turn back on. In addition, the user also lost any games
the blocked collaborator shared. This commit changes that.

First, the single friend zone is replaced with two mailbox zones per
pair, both named `friend-<pairKey>` and distinguished by owner: each
user owns their inbox — in their private database, shared read-write to
the friend — and joins the other's as their outbox. A friend is reached
by writing into their inbox; messages are received by reading one's own.
Blocking is now FriendController.block, a server-enforced downgrade of
the friend to readOnly on the inbox the user owns, and
FriendController.unblock restores readWrite. Both are unilateral and
reversible with no teardown and no re-pairing, and they converge across
the account's own devices through a versioned block Decision
(FriendEntity.blockVersion).

Second, blocking now hides the collaborator's shared games rather than
leaving them. InviteCoordinator.setGamesHidden flips GameEntity.isHidden
for the blocked author's participant games, the Game List fetch filters
`isHidden == NO`, and unblock reveals them again — so a block no longer
costs the user access to puzzles they may want back.

Existing single-zone friendships are carried over by a one-shot
migration. FriendController.migrateToMailboxes runs once at startup,
guarded by a NotificationState flag: the old shared zone already serves
the elected owner as their inbox, while the old participant stands up
its own inbox and announces it with a .friend bootstrap Ping written
into the old zone, which applyFriendPing accepts on the owner's next
sync. The announcement needs no shared game, but it does require both
sides to update — during version skew a not-yet-updated peer cannot
receive new invites in one direction.

The user-facing unblock surface is intentionally deferred. Blocked
friends are hidden from the Crossmates list for now, with the
unblockFriend action wired and ready for a later Settings screen.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/CrossmateApp.swift | 3+++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 5++---
MCrossmate/Services/AccountPushCoordinator.swift | 10+++++-----
MCrossmate/Services/AppServices.swift | 15++++++++++++---
MCrossmate/Services/InviteCoordinator.swift | 87++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
MCrossmate/Services/PlayerNamePublisher.swift | 10++++++----
MCrossmate/Sync/CloudQuery.swift | 22+++++++++++-----------
MCrossmate/Sync/CloudZones.swift | 58++++++++++++++++++++++++++++++++--------------------------
MCrossmate/Sync/FriendController.swift | 442++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
MCrossmate/Sync/FriendZone.swift | 34+++++++++++++++++++++++++++-------
MCrossmate/Sync/RecordSerializer.swift | 44+++++++++++++++++++++++++++++++-------------
MCrossmate/Sync/SyncEngine.swift | 48+++++++++---------------------------------------
MCrossmate/Views/Friends/FriendsView.swift | 15+++++++++------
MCrossmate/Views/GameList/GameListView.swift | 1+
MShared/NotificationState.swift | 14++++++++++++++
MTests/Unit/GameSummaryThumbnailTests.swift | 3---
MTests/Unit/NicknameDirectoryTests.swift | 6------
MTests/Unit/PlayerNamePublisherTests.swift | 31++++++++++---------------------
MTests/Unit/RecordSerializerTests.swift | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MTests/Unit/Sync/FriendControllerNicknameReplayTests.swift | 6------
MTests/Unit/Sync/FriendModelTests.swift | 45++++++++++++++++++++++++++++++++-------------
MTests/Unit/Sync/FriendZoneTests.swift | 26++++++++++++++++++++++++++
MTests/Unit/Sync/SessionMonitorTests.swift | 3---
23 files changed, 623 insertions(+), 390 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -65,6 +65,9 @@ struct CrossmateApp: App { .environment(\.blockFriend, { friendAuthorID in await services.invites.blockFriend(authorID: friendAuthorID) }) + .environment(\.unblockFriend, { friendAuthorID in + await services.invites.unblockFriend(authorID: friendAuthorID) + }) .environment(\.renameFriend, { friendAuthorID, nickname in do { try await services.friendController.setNickname( diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents @@ -23,6 +23,7 @@ <attribute name="hasPendingSave" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="id" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="isHidden" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="journalUploaded" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="lastReadOtherMoveAt" optional="YES" attributeType="Date" usesScalarValueType="NO" renamingIdentifier="lastSeenOtherMoveAt"/> <attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> @@ -98,12 +99,10 @@ <entity name="FriendEntity" representedClassName="FriendEntity" syncable="YES" codeGenerationType="class"> <attribute name="authorID" attributeType="String"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> - <attribute name="databaseScope" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="displayName" optional="YES" attributeType="String" defaultValueString=""/> <attribute name="displayNameVersion" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> - <attribute name="friendZoneName" attributeType="String"/> - <attribute name="friendZoneOwnerName" attributeType="String"/> <attribute name="isBlocked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> + <attribute name="blockVersion" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="nickname" optional="YES" attributeType="String" defaultValueString=""/> <attribute name="nicknameVersion" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="pairKey" attributeType="String"/> diff --git a/Crossmate/Services/AccountPushCoordinator.swift b/Crossmate/Services/AccountPushCoordinator.swift @@ -235,15 +235,15 @@ final class AccountPushCoordinator { req.predicate = NSPredicate(format: "isBlocked == NO") return ((try? ctx.fetch(req)) ?? []).compactMap { friend in guard let friendAuthorID = friend.authorID, - !friendAuthorID.isEmpty, - let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName + !friendAuthorID.isEmpty else { return nil } let pairKey = friend.pairKey ?? FriendZone.pairKey(localAuthorID, friendAuthorID) + // The friend decrypts our invite pushes by reading our key from + // their inbox — our outbox (shared DB, scope 1). return ( pairKey, - CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - friend.databaseScope + FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), + Int16(1) ) } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -105,11 +105,8 @@ enum DemoSeed { let friend = FriendEntity(context: ctx) friend.authorID = crossmate.id friend.createdAt = Date() - friend.databaseScope = 0 friend.displayName = crossmate.name friend.displayNameVersion = 0 - friend.friendZoneName = "demo-zone-\(crossmate.id)" - friend.friendZoneOwnerName = "_demo-you" friend.isBlocked = false friend.nickname = "" friend.nicknameVersion = 0 @@ -1053,6 +1050,18 @@ final class AppServices { syncMonitor.note("iCloud sync disabled — engine startup skipped") return } + // One-shot migration of pre-existing single-zone friendships to the + // two-mailbox model. Runs only once the engine is up (it enqueues zone + // creates, a share, and a bootstrap ping) and only with sync enabled. + // Detached so it never delays the foreground sync below. + if let localAuthorID = identity.currentID, !localAuthorID.isEmpty { + Task { [friendController, preferences] in + await friendController.migrateToMailboxes( + localAuthorID: localAuthorID, + localDisplayName: preferences.name + ) + } + } // The scene-active phase that fires alongside cold launch runs the // first fetch + push via `syncOnForeground`. Doing it here as well // would mean two concurrent CKSyncEngine fetches on a fresh engine. diff --git a/Crossmate/Services/InviteCoordinator.swift b/Crossmate/Services/InviteCoordinator.swift @@ -118,10 +118,10 @@ final class InviteCoordinator { } /// For each collaborative game with newly-known remote authors, asks the - /// `FriendController` to bootstrap a friendship. `establishIfOwner` is a - /// no-op for the non-owner and for already-established pairs (a defensive - /// backstop — the caller already fires this only on a Player record's - /// first sighting, so it runs about once per new collaborator). + /// `FriendController` to bootstrap a friendship. `establish` is symmetric + /// (both sides run it) and a no-op once this side's inbox is published (a + /// defensive backstop — the caller already fires this only on a Player + /// record's first sighting, so it runs about once per new collaborator). func reconcileFriendships(forGameIDs gameIDs: Set<UUID>) async { guard preferences.isICloudSyncEnabled, let localAuthorID = identity.currentID, @@ -162,7 +162,7 @@ final class InviteCoordinator { } for (gameID, remoteAuthorID) in candidates { - await friendController.establishIfOwner( + await friendController.establish( localAuthorID: localAuthorID, remoteAuthorID: remoteAuthorID, localDisplayName: preferences.name, @@ -411,13 +411,14 @@ final class InviteCoordinator { } } - /// Blocks a collaborator: marks the friendship blocked and tears down the - /// friend zone, leaves every game they currently share with us, and drops - /// their pending invites. Games we *own* that they joined are untouched. - /// Surfaced via `\.blockFriend`. + /// Blocks a collaborator: downgrades them to read-only on the inbox we own + /// (server-enforced, reversible — no teardown), hides every game they + /// currently share with us (reversibly — the share/access is kept, the game + /// just leaves the list), and drops their pending invites. Games we *own* + /// that they joined are untouched. Surfaced via `\.blockFriend`. func blockFriend(authorID: String) async { do { - try await friendController.blockAndTeardown(friendAuthorID: authorID) + try await friendController.block(friendAuthorID: authorID) } catch { announcements.post(Announcement( id: "block-friend-error-\(authorID)", @@ -430,13 +431,54 @@ final class InviteCoordinator { return } + await setGamesHidden(true, forAuthorID: authorID) + + // Drop their pending invites. Future inbound `.invite` Pings from a + // blocked sender are caught by `consumeStaleInvites`, which deletes + // the Ping so it doesn't re-fire across cold starts. + let vctx = persistence.viewContext + let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") + iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID) + for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } + if vctx.hasChanges { + try? vctx.save() + await refreshAppBadge("block friend") + } + } + + /// Reverses `blockFriend`: upgrades the friend back to read-write on the + /// inbox we own, so they can reach us again with no re-pairing, and reveals + /// the games hidden at block time. Surfaced via `\.unblockFriend`. + func unblockFriend(authorID: String) async { + do { + try await friendController.unblock(friendAuthorID: authorID) + } catch { + announcements.post(Announcement( + id: "unblock-friend-error-\(authorID)", + scope: .global, + severity: .error, + title: "Unblocking Failed", + body: error.localizedDescription, + dismissal: .manual + )) + return + } + await setGamesHidden(false, forAuthorID: authorID) + await refreshAppBadge("unblock friend") + } + + /// Hides (`true`) or reveals (`false`) the participant games the given + /// author shares with us, by flipping `GameEntity.isHidden`. Block uses this + /// instead of leaving the share, so unblock can restore the games with no + /// re-invite. Games we *own* (scope 0) are left alone, matching the prior + /// leave behaviour. A hidden game still syncs in the background; it is only + /// filtered out of the game list (`isHidden == NO`). + private func setGamesHidden(_ hidden: Bool, forAuthorID authorID: String) async { let ctx = persistence.container.newBackgroundContext() - let gameIDsToLeave: [UUID] = ctx.performAndWait { + await ctx.perform { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "databaseScope == 1") - var ids: [UUID] = [] for game in (try? ctx.fetch(req)) ?? [] { - guard let gid = game.id else { continue } var authors = Set<String>() if let owner = game.ckZoneOwnerName { authors.insert(owner) } let mReq = NSFetchRequest<MovesEntity>(entityName: "MovesEntity") @@ -449,24 +491,9 @@ final class InviteCoordinator { for p in (try? ctx.fetch(pReq)) ?? [] { if let a = p.authorID { authors.insert(a) } } - if authors.contains(authorID) { ids.append(gid) } + if authors.contains(authorID) { game.isHidden = hidden } } - return ids - } - for gid in gameIDsToLeave { - try? await shareController.leaveShare(gameID: gid) - } - - // Drop their pending invites. Future inbound `.invite` Pings from a - // blocked sender are caught by `consumeStaleInvites`, which deletes - // the Ping so it doesn't re-fire across cold starts. - let vctx = persistence.viewContext - let iReq = NSFetchRequest<InviteEntity>(entityName: "InviteEntity") - iReq.predicate = NSPredicate(format: "inviterAuthorID == %@", authorID) - for invite in (try? vctx.fetch(iReq)) ?? [] { vctx.delete(invite) } - if vctx.hasChanges { - try? vctx.save() - await refreshAppBadge("block friend") + if ctx.hasChanges { try? ctx.save() } } } diff --git a/Crossmate/Services/PlayerNamePublisher.swift b/Crossmate/Services/PlayerNamePublisher.swift @@ -169,12 +169,14 @@ final class PlayerNamePublisher { req.predicate = NSPredicate(format: "isBlocked == NO") let friends = (try? ctx.fetch(req)) ?? [] return friends.compactMap { friend in - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName + // 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 else { return nil } return ( - CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - friend.databaseScope + FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID), + Int16(1) ) } } diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -245,26 +245,26 @@ extension SyncEngine { private func friendInviteScanTargets( forScope scope: Int16 ) -> [(zoneID: CKRecordZone.ID, authorID: String)] { + // Invites we receive are written by the friend into our inbox — the + // zone we own (private DB, scope 0). Our outboxes (scope 1) hold only + // what we send, so there is nothing to scan there. Blocked friends are + // skipped: they can no longer write to us, and we don't surface their + // earlier invites. + guard scope == 0 else { return [] } let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - req.predicate = NSPredicate( - format: "databaseScope == %d AND isBlocked == NO", - scope - ) + req.predicate = NSPredicate(format: "isBlocked == NO") var seen = Set<String>() var result: [(zoneID: CKRecordZone.ID, authorID: String)] = [] for friend in (try? ctx.fetch(req)) ?? [] { - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName, + guard let pairKey = friend.pairKey, let authorID = friend.authorID else { continue } - let key = "\(ownerName)|\(zoneName)" + let zoneID = FriendZone.inboxZoneID(pairKey: pairKey) + let key = "\(zoneID.ownerName)|\(zoneID.zoneName)" guard seen.insert(key).inserted else { continue } - result.append(( - zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - authorID: authorID - )) + result.append((zoneID: zoneID, authorID: authorID)) } return result } diff --git a/Crossmate/Sync/CloudZones.swift b/Crossmate/Sync/CloudZones.swift @@ -88,28 +88,29 @@ extension SyncEngine { let createdAt = entity.createdAt ?? Date(timeIntervalSince1970: 0) result.append((CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), createdAt)) } - // Friend zones carry `.invite` / `.friend` pings but no - // GameEntity, so they're appended explicitly. The owner sees the - // zone in the private DB - // (scope 0); the participant sees it in the shared DB - // (scope 1). Blocked friends are skipped so we stop reading - // anything from them. Floor is `.distantPast`: any unseen - // invite should be processed. + // Friend mailboxes carry `.invite` / `.friend` pings but no + // GameEntity, so they're appended explicitly. Each friend has two + // zones: our inbox (we own it — private DB, scope 0) and the + // friend's inbox, i.e. our outbox (we joined it — shared DB, + // scope 1). At scope 0 we keep our inbox even for a blocked friend + // (we own it; the downgraded friend can't write to it anyway); at + // scope 1 we skip blocked friends — we won't write to them while + // blocked. Floor is `.distantPast`: any unseen invite should be + // processed. let friendReq = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - friendReq.predicate = NSPredicate( - format: "databaseScope == %d AND isBlocked == NO", - scope - ) + friendReq.predicate = scope == 0 + ? NSPredicate(value: true) + : NSPredicate(format: "isBlocked == NO") for friend in (try? ctx.fetch(friendReq)) ?? [] { - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName + guard let pairKey = friend.pairKey, + let authorID = friend.authorID else { continue } - let key = "\(ownerName)|\(zoneName)" + let zoneID = scope == 0 + ? FriendZone.inboxZoneID(pairKey: pairKey) + : FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID) + let key = "\(zoneID.ownerName)|\(zoneID.zoneName)" guard seen.insert(key).inserted else { continue } - result.append(( - CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - Date(timeIntervalSince1970: 0) - )) + result.append((zoneID, Date(timeIntervalSince1970: 0))) } return result } @@ -147,20 +148,25 @@ extension SyncEngine { nonisolated func friendZoneIDs(forScope scope: Int16) -> [CKRecordZone.ID] { let ctx = persistence.container.newBackgroundContext() return ctx.performAndWait { + // scope 0 → our inboxes (private DB, we own them, kept even when + // blocked); scope 1 → our outboxes (the friends' inboxes in the + // shared DB, skipped while blocked). Both derived from the pair. let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - req.predicate = NSPredicate( - format: "databaseScope == %d AND isBlocked == NO", - scope - ) + req.predicate = scope == 0 + ? NSPredicate(value: true) + : NSPredicate(format: "isBlocked == NO") var seen = Set<String>() var result: [CKRecordZone.ID] = [] for friend in (try? ctx.fetch(req)) ?? [] { - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName + guard let pairKey = friend.pairKey, + let authorID = friend.authorID else { continue } - let key = "\(ownerName)|\(zoneName)" + let zoneID = scope == 0 + ? FriendZone.inboxZoneID(pairKey: pairKey) + : FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID) + let key = "\(zoneID.ownerName)|\(zoneID.zoneName)" guard seen.insert(key).inserted else { continue } - result.append(CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)) + result.append(zoneID) } return result } diff --git a/Crossmate/Sync/FriendController.swift b/Crossmate/Sync/FriendController.swift @@ -2,17 +2,21 @@ import CloudKit import CoreData import Foundation -/// Sibling of `ShareController`, but for *friend* zones rather than game -/// zones. A friendship is a durable, pairwise channel: one custom zone -/// (`friend-<pairKey>`) carrying a zone-wide `CKShare` with the other user -/// added as a `.readWrite` participant. The zone is created in the elected -/// owner's private database and accepted into the other user's shared -/// database; thereafter either side can write `.invite` `Ping`s into it. +/// Sibling of `ShareController`, but for *friend* mailboxes rather than game +/// zones. A friendship is a durable, pairwise channel built from two mailbox +/// zones, both named `friend-<pairKey>` and distinguished by owner: each user +/// owns one (their *inbox*, in their private database, carrying a zone-wide +/// `CKShare` with the friend as a `.readWrite` participant) and joins the +/// other's (their *outbox*, in their shared database). You write to a friend by +/// writing into their inbox (your outbox); you receive by reading your own +/// inbox. Blocking downgrades the friend to `.readOnly` on the inbox you own — +/// server-enforced and reversible by upgrading back, with no teardown. /// -/// Bootstrap rides the *game* zone the two users already share: the owner -/// enqueues a `.friend` `Ping` whose `payload` carries the friend-zone share -/// URL. The other device applies it (`applyFriendPing`) and accepts the -/// share without any out-of-band link. +/// Bootstrap rides the *game* zone the two users already share and is +/// symmetric: each side ensures its own inbox exists and enqueues a `.friend` +/// `Ping` whose `payload` carries that inbox's share URL. The other device +/// applies it (`applyFriendPing`) and accepts the share — gaining write access +/// to that inbox — without any out-of-band link. @MainActor final class FriendController { /// Mints (if needed) and publishes this device's per-pair invite encryption @@ -58,60 +62,42 @@ final class FriendController { case payloadEncodingFailed } - // MARK: - Owner side - - /// Creates the friendship if this device is the elected owner and no - /// friendship for the pair exists yet. No-ops for the non-owner (it - /// waits for the `.friend` Ping) and for an already-established pair. - /// `viaGameID` is the shared game whose zone carries the bootstrap Ping. - func establishIfOwner( + // MARK: - Bootstrap + + /// Ensures this device's *inbox* mailbox for the pair exists and is shared + /// to the friend, then enqueues a `.friend` Ping carrying the inbox share + /// URL so the friend can accept it and gain write access. Symmetric: both + /// sides run this for the pair — there is no elected owner. Idempotent — a + /// no-op once this side's inbox is established (the friend's own + /// `applyFriendPing` records their half independently). `viaGameID` is the + /// shared game whose zone carries the bootstrap Ping. The local display name + /// is *not* seeded here: it is written into our outbox by `applyFriendPing`, + /// which is the first moment we have write access to it. + func establish( localAuthorID: String, remoteAuthorID: String, localDisplayName: String?, viaGameID: UUID ) async { guard !remoteAuthorID.isEmpty, - localAuthorID != remoteAuthorID, - FriendZone.isOwner(localAuthorID: localAuthorID, remoteAuthorID: remoteAuthorID) + localAuthorID != remoteAuthorID else { return } let pairKey = FriendZone.pairKey(localAuthorID, remoteAuthorID) - if friendExists(pairKey: pairKey) { return } + if inboxEstablished(pairKey: pairKey) { return } + let zoneID = FriendZone.inboxZoneID(pairKey: pairKey) syncMonitor?.recordStart("establish friendship") do { - let zoneName = FriendZone.zoneName(pairKey: pairKey) - let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName) - - // Every field of the local friendship is deterministic or already - // in hand, so any owner-side device can record it without reading - // anything back from the share. - let recordLocalFriendship = { - self.persistFriend( - authorID: remoteAuthorID, - pairKey: pairKey, - zoneName: zoneName, - zoneOwnerName: CKCurrentUserDefaultName, - databaseScope: 0 - ) - } - try await createZone(zoneID) // A sibling device on this same iCloud account may have already - // created the zone-wide share. `FriendEntity` is local-only, so - // the `friendExists` check above can't see its work and we still - // reach here. If the share exists we adopt the friendship locally - // *without* re-creating the share or re-enqueuing the `.friend` - // Ping — the sibling already delivered it to the friend. + // created the inbox share; the per-device marker can't see its + // work. If the share exists we adopt locally *without* re-creating + // it or re-enqueuing the `.friend` Ping — already delivered. if try await existingZoneWideShare(zoneID: zoneID) != nil { - recordLocalFriendship() - await seedOwnNameDecision( - localAuthorID: localAuthorID, - localDisplayName: localDisplayName, - zoneID: zoneID, - scope: 0 - ) + persistFriend(authorID: remoteAuthorID, pairKey: pairKey) + markInboxEstablished(pairKey: pairKey) syncMonitor?.recordSuccess("establish friendship") return } @@ -123,27 +109,17 @@ final class FriendController { addingParticipant: remoteAuthorID ) } catch let error as CKError where error.code == .serverRecordChanged { - // Lost the create race to a sibling between the check above - // and this save. The share now exists; adopt it as above. - recordLocalFriendship() - await seedOwnNameDecision( - localAuthorID: localAuthorID, - localDisplayName: localDisplayName, - zoneID: zoneID, - scope: 0 - ) + // 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) syncMonitor?.recordSuccess("establish friendship") return } guard let url = share.url else { throw FriendError.missingShareURL } - recordLocalFriendship() - await seedOwnNameDecision( - localAuthorID: localAuthorID, - localDisplayName: localDisplayName, - zoneID: zoneID, - scope: 0 - ) + persistFriend(authorID: remoteAuthorID, pairKey: pairKey) + markInboxEstablished(pairKey: pairKey) let payload = FriendZone.BootstrapPayload( friendShareURL: url.absoluteString, @@ -203,29 +179,38 @@ final class FriendController { let payload = FriendZone.BootstrapPayload.decode(ping.payload) else { return } guard FriendZone.canAcceptBootstrap(payload, localAuthorID: localAuthorID) else { return } - if friendExists(pairKey: payload.pairKey) { return } + // Gate on whether we've accepted *their* inbox share, not on whether a + // 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 } guard let url = URL(string: payload.friendShareURL) else { return } syncMonitor?.recordStart("accept friendship") do { let metadata = try await fetchShareMetadata(url: url) try await accept(metadata) - let zoneID = metadata.share.recordID.zoneID - persistFriend( - authorID: payload.ownerAuthorID, - pairKey: payload.pairKey, - zoneName: zoneID.zoneName, - zoneOwnerName: zoneID.ownerName, - databaseScope: 1 - ) + // 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) + persistFriend(authorID: payload.ownerAuthorID, pairKey: payload.pairKey) if let localAuthorID, !localAuthorID.isEmpty { + // Tell the friend our name by writing into their inbox (our + // outbox) — the first moment we have write access to it. await seedOwnNameDecision( localAuthorID: localAuthorID, localDisplayName: localDisplayName, - zoneID: zoneID, + zoneID: outboxZoneID, scope: 1 ) } + // Publish our per-pair invite encryption key into the outbox so the + // friend can decrypt invite pushes we seal; self-heals on the next + // push-registration refresh if this misses. + Task { [weak self] in + await self?.publishFriendInvitationKey?(payload.pairKey, outboxZoneID, 1) + } syncMonitor?.recordSuccess("accept friendship") // `applyFriendPing` runs inside the `onPings` CKSyncEngine // delegate callback. Awaiting a call back into CKSyncEngine from @@ -250,6 +235,108 @@ final class FriendController { } } + // MARK: - Migration + + /// One-shot migration of pre-mailbox friendships (a single elected-owner + /// shared zone) to the two-mailbox model. The old shared zone already + /// serves as the elected owner's inbox; the old participant must stand up + /// their own inbox and announce it. The announcement rides the old zone + /// itself (both sides retain read-write access), so no shared game is + /// needed — the owner side auto-accepts via `applyFriendPing` on its next + /// sync. Idempotent: the per-pair `inboxEstablished` marker and the + /// existing-share check make a re-run a no-op, and a device-level + /// `NotificationState` flag skips it entirely after the first full success. + func migrateToMailboxes(localAuthorID: String, localDisplayName: String?) async { + guard NotificationState.friendMailboxMigrationNeeded(), + !localAuthorID.isEmpty + else { return } + + let ctx = persistence.viewContext + let pairs: [(pairKey: String, authorID: String)] = ctx.performAndWait { + let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") + return ((try? ctx.fetch(req)) ?? []).compactMap { friend in + guard let pairKey = friend.pairKey, let authorID = friend.authorID + else { return nil } + return (pairKey, authorID) + } + } + + var allSucceeded = true + for (pairKey, friendAuthorID) in pairs where !inboxEstablished(pairKey: pairKey) { + let inboxZoneID = FriendZone.inboxZoneID(pairKey: pairKey) + syncMonitor?.recordStart("migrate friendship") + do { + try await createZone(inboxZoneID) + + if try await existingZoneWideShare(zoneID: inboxZoneID) != nil { + // We were the elected owner — the old shared zone is already + // 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) + syncMonitor?.recordSuccess("migrate friendship") + continue + } + + // We were the participant: stand up our new inbox, share it to + // the friend, and announce it by writing a `.friend` bootstrap + // Ping into the old zone — our outbox (the friend's inbox), where + // we still have write access. Our outbox is that old zone, which + // we already joined, so mark it accepted. + let share = try await saveZoneWideShare( + zoneID: inboxZoneID, + addingParticipant: friendAuthorID + ) + guard let url = share.url, + let encoded = FriendZone.BootstrapPayload( + friendShareURL: url.absoluteString, + pairKey: pairKey, + ownerAuthorID: localAuthorID + ).encodedString() + else { + allSucceeded = false + syncMonitor?.recordError("migrate friendship", FriendError.missingShareURL) + continue + } + markInboxEstablished(pairKey: pairKey) + markOutboxAccepted(pairKey: pairKey) + await syncEngine.enqueueFriendZonePing( + kind: .friend, + gameID: Self.migrationSentinelGameID(pairKey: pairKey), + gameTitle: "", + authorID: localAuthorID, + playerName: localDisplayName ?? "", + addressee: friendAuthorID, + friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), + friendZoneScope: 1, + payload: encoded + ) + syncMonitor?.recordSuccess("migrate friendship") + } catch { + allSucceeded = false + syncMonitor?.recordError("migrate friendship", error) + } + } + + // Only burn the one-shot flag once every pair is converted, so a + // transient CloudKit failure retries on the next launch. + if allSucceeded { + NotificationState.markFriendMailboxMigrated() + } + } + + /// Deterministic synthetic game ID for a migration bootstrap Ping. The + /// `.friend` Ping record name is keyed by a game UUID, but a migration has + /// no game; `applyFriendPing` ignores it and reads the pairKey from the + /// payload. Derived from the (64-hex-char) pairKey so it is stable per pair. + private static func migrationSentinelGameID(pairKey: String) -> UUID { + let hex = String(pairKey.prefix(32)) + guard hex.count == 32 else { return UUID() } + let s = Array(hex) + let formatted = "\(String(s[0..<8]))-\(String(s[8..<12]))-\(String(s[12..<16]))-\(String(s[16..<20]))-\(String(s[20..<32]))" + return UUID(uuidString: formatted) ?? UUID() + } + // MARK: - Re-invite /// Writes an `.invite` Ping carrying the game's share URL into the friend @@ -276,9 +363,7 @@ final class FriendController { throw FriendError.friendNotFound } guard !friend.isBlocked else { throw FriendError.friendBlocked } - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName - else { throw FriendError.friendNotFound } + guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } let payload = FriendZone.InvitePayload( gameShareURL: gameShareURL.absoluteString, @@ -296,8 +381,8 @@ final class FriendController { authorID: inviterAuthorID, playerName: inviterName, addressee: friendAuthorID, - friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - friendZoneScope: friend.databaseScope, + friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: friendAuthorID), + friendZoneScope: 1, payload: encoded ) } @@ -323,9 +408,7 @@ final class FriendController { throw FriendError.friendNotFound } guard !friend.isBlocked else { throw FriendError.friendBlocked } - guard let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName - else { throw FriendError.friendNotFound } + guard let pairKey = friend.pairKey else { throw FriendError.friendNotFound } await syncEngine.enqueueFriendZonePing( kind: .decline, @@ -334,8 +417,8 @@ final class FriendController { authorID: declinerAuthorID, playerName: declinerName, addressee: inviterAuthorID, - friendZoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - friendZoneScope: friend.databaseScope + friendZoneID: FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: inviterAuthorID), + friendZoneScope: 1 ) } @@ -351,67 +434,99 @@ final class FriendController { req.predicate = NSPredicate(format: "authorID == %@", friendAuthorID) req.fetchLimit = 1 guard let friend = try? ctx.fetch(req).first, - let zoneName = friend.friendZoneName, - let ownerName = friend.friendZoneOwnerName + let pairKey = friend.pairKey else { return } + // A consumed Ping is always one we *received* — an `.invite` we were + // sent, or a `.decline` sent to us — so it lives in our own inbox (the + // zone we own, private engine). await syncEngine.deletePing( recordName: recordName, - zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName), - databaseScope: friend.databaseScope + zoneID: FriendZone.inboxZoneID(pairKey: pairKey), + databaseScope: 0 ) } - // MARK: - Block + // MARK: - Block / Unblock + + /// Blocks a friend by downgrading them to `.readOnly` on the inbox we own, + /// so they can no longer write invites / name / key records to us. This is + /// server-enforced and inherently account-wide (one share), so every device + /// on this account gets it for free; it is fully reversible by `unblock`, + /// which upgrades the same participant back to `.readWrite`. The zone and + /// share are left intact — nothing is torn down. The versioned `block` + /// Decision converges the UI flag across our own devices. + func block(friendAuthorID: String) async throws { + try await setBlocked(friendAuthorID: friendAuthorID, blocked: true) + } - /// Marks the friend blocked and tears down the channel so nothing further - /// can arrive from them: the owner deletes the friend zone outright; a - /// participant leaves by deleting the zone-wide share. The `FriendEntity` - /// row is kept (as a blocked tombstone) so future `.invite` Pings are - /// suppressed and the zone stays out of `knownZones`. - func blockAndTeardown(friendAuthorID: String) async throws { + /// Reverses `block`: upgrades the friend back to `.readWrite` on our inbox + /// and clears the local/UI flag. No carrier and no re-acceptance — the + /// channel was never torn down. + func unblock(friendAuthorID: String) async throws { + try await setBlocked(friendAuthorID: friendAuthorID, blocked: false) + } + + private func setBlocked(friendAuthorID: String, blocked: Bool) 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 { return } - let scope = friend.databaseScope - let zoneName = friend.friendZoneName - let ownerName = friend.friendZoneOwnerName - friend.isBlocked = true + guard let friend = try ctx.fetch(req).first, + let pairKey = friend.pairKey + else { return } + let version = friend.blockVersion + 1 + friend.isBlocked = blocked + friend.blockVersion = version try ctx.save() - // Make the block authoritative across the user's own devices. Written - // before the channel teardown so the durable fact survives even a - // partial teardown; `applyDecisionRecord` projects it onto a blocked - // `FriendEntity` on every other device. - await syncEngine.enqueueDecision(kind: "block", key: friendAuthorID) + // 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 + ) - guard let zoneName, let ownerName else { return } - let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName) - syncMonitor?.recordStart("block friend") + let label = blocked ? "block friend" : "unblock friend" + syncMonitor?.recordStart(label) do { - if scope == 0 { - // We own the friend zone — deleting it revokes the share for - // both sides. - try await deleteZone(zoneID, in: container.privateCloudDatabase) - } else { - // Participant — leave by deleting the zone-wide share record. - let shareID = CKRecord.ID( - recordName: CKRecordNameZoneWideShare, - zoneID: zoneID - ) - do { - try await container.sharedCloudDatabase.deleteRecord(withID: shareID) - } catch let error as CKError - where error.code == .unknownItem || error.code == .zoneNotFound { - // Already gone — nothing to do. - } - } - syncMonitor?.recordSuccess("block friend") + try await setInboxParticipantPermission( + friendAuthorID: friendAuthorID, + pairKey: pairKey, + permission: blocked ? .readOnly : .readWrite + ) + syncMonitor?.recordSuccess(label) } catch { - syncMonitor?.recordError("block friend", error) + syncMonitor?.recordError(label, error) + throw error + } + } + + /// Fetches the zone-wide `CKShare` on our inbox and sets the friend + /// participant's permission, then saves. Fetch-then-save uses the server's + /// current change tag so the permission write doesn't lose a tag race. + private func setInboxParticipantPermission( + friendAuthorID: String, + pairKey: String, + permission: CKShare.ParticipantPermission + ) async throws { + let zoneID = FriendZone.inboxZoneID(pairKey: pairKey) + let shareID = CKRecord.ID(recordName: CKRecordNameZoneWideShare, zoneID: zoneID) + let database = container.privateCloudDatabase + guard let share = try await database.record(for: shareID) as? CKShare else { + throw FriendError.invalidShareRecord + } + guard let participant = share.participants.first(where: { + $0.userIdentity.userRecordID?.recordName == friendAuthorID + }) else { + throw FriendError.participantNotFound } + participant.permission = permission + _ = try await database.save(share) } // MARK: - Rename @@ -449,21 +564,6 @@ final class FriendController { // MARK: - CloudKit helpers - private func deleteZone( - _ zoneID: CKRecordZone.ID, - in database: CKDatabase - ) async throws { - try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in - let op = CKModifyRecordZonesOperation( - recordZonesToSave: nil, - recordZoneIDsToDelete: [zoneID] - ) - op.qualityOfService = .userInitiated - op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } - database.add(op) - } - } - private func createZone(_ zoneID: CKRecordZone.ID) async throws { try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in let op = CKModifyRecordZonesOperation( @@ -571,34 +671,42 @@ final class FriendController { } } - // MARK: - Core Data + // MARK: - Bootstrap dedup markers - /// True if *any* `FriendEntity` for the pair exists — including a blocked - /// one. This is deliberate for v1: a blocked friendship is permanent, so - /// `establishIfOwner` / `applyFriendPing` short-circuit here and never - /// re-bootstrap a channel the user has torn down. Re-friending a blocked - /// collaborator is intentionally out of scope; lifting it later means - /// adding an explicit unblock path, not changing this guard. - private func friendExists(pairKey: String) -> Bool { - let ctx = persistence.viewContext - let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - req.predicate = NSPredicate(format: "pairKey == %@", pairKey) - req.fetchLimit = 1 - return ((try? ctx.count(for: req)) ?? 0) > 0 + // 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* /// written here — it arrives exclusively via the friend's `name` Decision /// (`RecordSerializer.applyDecisionRecord`); until that syncs, the invite /// surfaces fall back to the freshest per-game Player snapshot, then - /// "Player". - private func persistFriend( - authorID: String, - pairKey: String, - zoneName: String, - zoneOwnerName: String, - databaseScope: Int16 - ) { + /// "Player". Both mailbox zones are derivable from `pairKey` + `authorID`, + /// so no zone fields are stored. The invite encryption key is published from + /// `applyFriendPing` instead — the first point we have outbox write access. + private func persistFriend(authorID: String, pairKey: String) { let ctx = persistence.viewContext let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "pairKey == %@", pairKey) @@ -606,18 +714,10 @@ final class FriendController { let entity = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) entity.authorID = authorID entity.pairKey = pairKey - entity.friendZoneName = zoneName - entity.friendZoneOwnerName = zoneOwnerName - entity.databaseScope = databaseScope if entity.createdAt == nil { entity.createdAt = Date() } do { try ctx.save() Task { [weak self] in - await self?.publishFriendInvitationKey?( - pairKey, - CKRecordZone.ID(zoneName: zoneName, ownerName: zoneOwnerName), - databaseScope - ) await self?.applyAccountNicknameDecisionIfPresent(for: authorID) } } catch { diff --git a/Crossmate/Sync/FriendZone.swift b/Crossmate/Sync/FriendZone.swift @@ -1,15 +1,18 @@ +import CloudKit import CryptoKit import Foundation -/// Pure helpers for the friendship channel. A friendship is realised as one -/// custom CloudKit zone (`friend-<pairKey>`) carrying a zone-wide `CKShare` -/// with the other user added as a `.readWrite` participant. The zone lives in -/// the *owner's* private database and appears in the *participant's* shared -/// database; both sides can write `Ping` records into it. +/// Pure helpers for the friendship channel. A friendship is realised as *two* +/// mailbox zones, both named `friend-<pairKey>` and distinguished by owner: +/// each user owns one (their inbox, in their private database) and is a +/// `.readWrite` participant in the other's (their outbox, appearing in their +/// shared database). You write to the friend by writing into their inbox (your +/// outbox); you receive by reading your own inbox. Blocking is a permission +/// downgrade on the inbox *you own*, so it is server-enforced and reversible. /// /// Everything here is deterministic and side-effect-free so both devices -/// derive the same zone name and elect the same owner without coordination. -/// The CloudKit lifecycle lives in `FriendController`. +/// derive the same zone names without coordination. The CloudKit lifecycle +/// lives in `FriendController`. enum FriendZone { /// Zone-name prefix. The shared-DB sync paths branch on this to keep a /// friend zone from being mistaken for a `game-<UUID>` zone. @@ -35,6 +38,23 @@ enum FriendZone { zoneName.hasPrefix(zonePrefix) } + /// The mailbox this device owns for the pair — its *inbox*. The friend + /// writes invites / name / key records into it; this device reads them. It + /// lives in this device's private database, so the owner name is the + /// current-user default. (`scope == 0`.) + static func inboxZoneID(pairKey: String) -> CKRecordZone.ID { + CKRecordZone.ID(zoneName: zoneName(pairKey: pairKey), ownerName: CKCurrentUserDefaultName) + } + + /// The friend's mailbox — this device's *outbox*. This device is a + /// `.readWrite` participant and writes invites / name / key records here for + /// the friend to read. It appears in this device's shared database, owned by + /// the friend, whose CloudKit user-record name is `friendAuthorID` — which + /// is exactly the shared-zone owner name. (`scope == 1`.) + static func outboxZoneID(pairKey: String, friendAuthorID: String) -> CKRecordZone.ID { + CKRecordZone.ID(zoneName: zoneName(pairKey: pairKey), ownerName: friendAuthorID) + } + /// 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/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -191,6 +191,14 @@ enum RecordSerializer { static let nicknameDecisionKind = "nickname" static let encryptionKeyDecisionKind = "encryptionKey" + /// Kind for the friend-block Decision: `decision-block-<authorID>`, payload + /// `"1"` blocked / `"0"` unblocked, `version` = this user's monotonic block + /// generation for that friend. Lives only in the account zone and converges + /// the *UI* flag across the user's own devices — the actual enforcement is + /// the server-side `.readOnly` downgrade on the inbox share, which is + /// inherently account-wide. + static let blockDecisionKind = "block" + static let accountDecisionKind = "account" static let accountPushAddressDecisionKey = "pushAddress" /// Key for the account-wide push *secret* decision. The secret is the HMAC @@ -1044,13 +1052,20 @@ enum RecordSerializer { else { return false } switch kind { - case "block": + case blockDecisionKind: + // Versioned so a stale copy can't resurrect a cleared block (or + // re-clear a fresh one). Payload `"0"` means unblocked; anything + // else (including a legacy payload-less record) means blocked. + let version = decisionVersion(record) + let blocked = (record["payload"] as? String) != "0" let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "authorID == %@", key) req.fetchLimit = 1 let friend = (try? ctx.fetch(req).first) ?? FriendEntity(context: ctx) + guard version >= friend.blockVersion else { return false } friend.authorID = key - friend.isBlocked = true + friend.isBlocked = blocked + friend.blockVersion = version if friend.pairKey == nil, let localAuthorID, !localAuthorID.isEmpty { friend.pairKey = FriendZone.pairKey(localAuthorID, key) @@ -1083,17 +1098,22 @@ enum RecordSerializer { let name = record["payload"] as? String, !name.isEmpty else { return false } - // Provenance: both participants can write into a friend zone, so - // a name Decision is only honored when the zone is *the* pairwise - // zone for (us, key) — the zone name embeds a hash of the author - // pair, so the claimed subject is verifiable without trusting the - // record. This also rejects a name Decision for a third party - // misdelivered into an unrelated zone. + // Provenance: a name Decision is only honored when the zone is *the* + // pairwise mailbox for (us, key) — the zone name embeds a hash of + // the author pair, so the claimed subject is verifiable without + // trusting the record. This also rejects a name Decision for a third + // party misdelivered into an unrelated zone. let pairKey = FriendZone.pairKey(localAuthorID, key) let zoneID = record.recordID.zoneID guard zoneID.zoneName == FriendZone.zoneName(pairKey: pairKey) else { return false } + // The friend writes their name into *our inbox* (their outbox), so + // a friend's name only ever arrives via the zone we own — the + // private engine, scope 0. Reject one seen at scope 1 (that would be + // the friend's own inbox, not the channel they tell us their name + // through). + guard databaseScope == 0 else { return false } let version = decisionVersion(record) let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") req.predicate = NSPredicate(format: "pairKey == %@", pairKey) @@ -1108,14 +1128,12 @@ enum RecordSerializer { } // No local row: the bootstrap evidence (game zones, `.friend` // Ping) may be long gone on a restored device, but the name - // Decision arriving from a live friend zone is itself proof of - // the friendship — resurrect the row from the zone it rode in on. + // Decision arriving from our inbox is itself proof of the + // friendship — resurrect the row. Both mailbox zones are derivable + // from `pairKey` + `authorID`, so no zone fields are stored. let friend = FriendEntity(context: ctx) friend.authorID = key friend.pairKey = pairKey - friend.friendZoneName = zoneID.zoneName - friend.friendZoneOwnerName = zoneID.ownerName - friend.databaseScope = databaseScope friend.createdAt = Date() friend.displayName = name friend.displayNameVersion = version diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -12,9 +12,13 @@ extension EnvironmentValues { @Entry var acceptInvite: ((String, String) async throws -> Void)? = nil /// `(gameID)` — declines a pending game invite (tombstone + badge refresh). @Entry var declineInvite: ((UUID) async throws -> Void)? = nil - /// `(friendAuthorID)` — blocks a collaborator: suppress future invites, - /// leave their shared games, tear down the friend zone. + /// `(friendAuthorID)` — blocks a collaborator: downgrades them to read-only + /// on the inbox we own (server-enforced, reversible) and leaves their + /// shared games. @Entry var blockFriend: ((String) async -> Void)? = nil + /// `(friendAuthorID)` — reverses `blockFriend`: upgrades the friend back to + /// read-write on our inbox, restoring the channel with no re-pairing. + @Entry var unblockFriend: ((String) async -> Void)? = nil /// `(friendAuthorID, nickname)` — sets the user's private nickname for a /// friend; an empty nickname clears it back to the friend's own name. @Entry var renameFriend: ((String, String) async -> Void)? = nil @@ -83,8 +87,8 @@ actor SyncEngine { /// `decisionStateKey`. Mirrors `pendingDecisionPayloads`' lifecycle and /// durability: the version must survive an app kill so a rebuilt rotation /// re-asserts at the right generation rather than a stale one. The push - /// secret and display name are versioned; unversioned decisions - /// (block/left/pushAddress) have no entry and stay write-once on conflict. + /// secret, display name and block flag are versioned; unversioned decisions + /// (left/pushAddress) have no entry and stay write-once on conflict. private var pendingDecisionVersions: [String: Int64] = [:] private static let pendingDecisionVersionsDefaultsKey = @@ -327,7 +331,6 @@ actor SyncEngine { Task { await purgeLegacyLeasePings_v1() } Task { await purgeLegacyInvitePings_v1() } Task { await purgeStaleHailPings_v1() } - Task { await purgeDebugPreviewFriends_v1() } Task { await purgeLegacyPlayPings_v1() } } @@ -843,39 +846,6 @@ actor SyncEngine { } } - /// One-shot local cleanup for pre-release debug friend rows whose - /// `friend-debug-preview-*` zones no longer exist or have invalid - /// participants. These rows are local metadata, but while present they - /// make the ping fast-path query dead zones on every poll. - func purgeDebugPreviewFriends_v1() async { - guard NotificationState.debugPreviewFriendPurgeNeeded() else { return } - let ctx = persistence.container.newBackgroundContext() - let deleted = ctx.performAndWait { - let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - req.predicate = NSPredicate(format: "friendZoneName BEGINSWITH %@", "friend-debug-preview-") - let rows = (try? ctx.fetch(req)) ?? [] - for row in rows { - ctx.delete(row) - } - do { - if ctx.hasChanges { - try ctx.save() - } - return rows.count - } catch { - ctx.rollback() - return -1 - } - } - - guard deleted >= 0 else { - await trace("debug-preview friend purge failed") - return - } - NotificationState.markDebugPreviewFriendPurged() - await trace("debug-preview friend purge: deleted \(deleted) row(s)") - } - /// Registers a directed Ping (`.invite` or `.decline`) into an existing /// *friend* zone. Unlike `enqueuePing`, the target zone is the friend zone /// (not the game zone), so the zone and engine are passed in explicitly: @@ -1881,7 +1851,7 @@ actor SyncEngine { "(\(intended) > \(serverVersion)) — overwriting" ) } else { - // Write-once (block/left/pushAddress) or an equal/older + // Write-once (left/pushAddress) or an equal/older // version: the durable fact already on the server wins. // Drop the pending change so the record doesn't // retry-loop, and adopt the winner's payload straight off diff --git a/Crossmate/Views/Friends/FriendsView.swift b/Crossmate/Views/Friends/FriendsView.swift @@ -7,11 +7,14 @@ struct FriendNewGameTarget: Identifiable, Equatable { var id: String { authorID } } -/// Sheet listing the user's crossmates (friends), presented from the game -/// list. Friends are accumulated automatically the first time you collaborate -/// with someone (see `FriendController`); the actions are renaming (a private -/// nickname via `\.renameFriend`) and blocking, which tears down the pairwise -/// channel via `\.blockFriend`. +/// Sheet listing the user's (non-blocked) crossmates (friends), presented from +/// the game list. Friends are accumulated automatically the first time you +/// collaborate with someone (see `FriendController`); the actions are renaming +/// (a private nickname via `\.renameFriend`) and blocking via `\.blockFriend` +/// (a reversible, server-enforced read-only downgrade on the inbox we own). +/// Blocked crossmates are intentionally hidden from this list; unblocking will +/// live on a dedicated surface (Settings) — see TODO #10. The `\.unblockFriend` +/// action and `FriendController.unblock` mechanism are already wired for it. struct FriendsView: View { var onStartGame: (FriendNewGameTarget) -> Void = { _ in } @@ -84,7 +87,7 @@ struct FriendsView: View { Button("Cancel", role: .cancel) {} } message: { let name = blockTarget?.resolvedDisplayName ?? "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.") } } } diff --git a/Crossmate/Views/GameList/GameListView.swift b/Crossmate/Views/GameList/GameListView.swift @@ -17,6 +17,7 @@ struct GameListView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FetchRequest( sortDescriptors: [], + predicate: NSPredicate(format: "isHidden == NO"), animation: .default ) private var games: FetchedResults<GameEntity> diff --git a/Shared/NotificationState.swift b/Shared/NotificationState.swift @@ -106,6 +106,7 @@ enum NotificationState { private static let staleHailPurgeKey = "migration.staleHailPurge.v1" private static let debugPreviewFriendPurgeKey = "migration.debugPreviewFriendPurge.v1" private static let legacyPlayPingPurgeKey = "migration.legacyPlayPingPurge.v1" + private static let friendMailboxMigrationKey = "migration.friendMailboxes.v1" /// True if the one-shot cleanup of legacy `.opened`/`.closed` lease pings /// has not yet run successfully on this device. The flag is per-device @@ -171,6 +172,19 @@ enum NotificationState { defaults?.set(true, forKey: legacyPlayPingPurgeKey) } + /// True if the one-shot migration of pre-mailbox friendships (a single + /// elected-owner shared zone) to the two-mailbox model has not yet run on + /// this device. + static func friendMailboxMigrationNeeded() -> Bool { + defaults?.bool(forKey: friendMailboxMigrationKey) == false + } + + /// Records that the friend-mailbox migration completed so the next launch + /// skips it. + static func markFriendMailboxMigrated() { + defaults?.set(true, forKey: friendMailboxMigrationKey) + } + /// Exposes the (testing-aware) App Group defaults to siblings in this /// module — currently `BadgeState`, which shares storage but lives in /// its own namespace. diff --git a/Tests/Unit/GameSummaryThumbnailTests.swift b/Tests/Unit/GameSummaryThumbnailTests.swift @@ -142,11 +142,8 @@ struct GameSummaryThumbnailTests { let friend = FriendEntity(context: ctx) friend.authorID = "_Alice" friend.createdAt = Date() - friend.databaseScope = 0 friend.displayName = "Alice" friend.displayNameVersion = 0 - friend.friendZoneName = "friend-zone" - friend.friendZoneOwnerName = "_Local" friend.isBlocked = false friend.nickname = "Ace" friend.nicknameVersion = 1 diff --git a/Tests/Unit/NicknameDirectoryTests.swift b/Tests/Unit/NicknameDirectoryTests.swift @@ -68,9 +68,6 @@ struct NicknameDirectoryTests { let friend = FriendEntity(context: ctx) friend.authorID = authorID friend.pairKey = "pair-\(authorID)" - friend.friendZoneName = "friend-pair-\(authorID)" - friend.friendZoneOwnerName = "_owner" - friend.databaseScope = 0 friend.createdAt = Date() friend.displayName = displayName friend.nickname = nickname @@ -124,9 +121,6 @@ struct NicknameDirectoryTests { let friend = FriendEntity(context: ctx) friend.authorID = "_alice" friend.pairKey = "pair-alice" - friend.friendZoneName = "friend-pair-alice" - friend.friendZoneOwnerName = "_owner" - friend.databaseScope = 0 friend.createdAt = Date() friend.nickname = "Mum" diff --git a/Tests/Unit/PlayerNamePublisherTests.swift b/Tests/Unit/PlayerNamePublisherTests.swift @@ -35,18 +35,12 @@ struct PlayerNamePublisherTests { private func addFriend( to persistence: PersistenceController, authorID: String, - zoneName: String, - zoneOwnerName: String = CKCurrentUserDefaultName, - scope: Int16 = 0, blocked: Bool = false ) throws { let ctx = persistence.viewContext let friend = FriendEntity(context: ctx) friend.authorID = authorID friend.pairKey = "pair-\(authorID)" - friend.friendZoneName = zoneName - friend.friendZoneOwnerName = zoneOwnerName - friend.databaseScope = scope friend.isBlocked = blocked friend.createdAt = Date() try ctx.save() @@ -92,18 +86,9 @@ struct PlayerNamePublisherTests { func broadcastNameFansOutDecisions() async throws { let persistence = makeTestPersistence() let authorID = freshAuthorID() - try addFriend( - to: persistence, authorID: "_bob", - zoneName: "friend-bob", scope: 0 - ) - try addFriend( - to: persistence, authorID: "_carol", - zoneName: "friend-carol", zoneOwnerName: "_carol-owner", scope: 1 - ) - try addFriend( - to: persistence, authorID: "_mallory", - zoneName: "friend-mallory", blocked: true - ) + try addFriend(to: persistence, authorID: "_bob") + try addFriend(to: persistence, authorID: "_carol") + try addFriend(to: persistence, authorID: "_mallory", blocked: true) let prefs = PlayerPreferences( local: UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")! @@ -122,13 +107,17 @@ struct PlayerNamePublisherTests { #expect(spy.decisions.allSatisfy { $0.authorID == authorID }) #expect(spy.decisions.allSatisfy { $0.name == "Alice" }) #expect(spy.decisions.allSatisfy { $0.version == 1 }) + // A friend's name is written into their inbox — our outbox: shared + // scope, owned by the friend, both zones named `friend-<pairKey>`. + let bobZone = FriendZone.outboxZoneID(pairKey: "pair-_bob", friendAuthorID: "_bob") + let carolZone = FriendZone.outboxZoneID(pairKey: "pair-_carol", friendAuthorID: "_carol") let zoneNames = Set(spy.decisions.map(\.zoneID.zoneName)) #expect(zoneNames == [ - RecordSerializer.accountZoneID.zoneName, "friend-bob", "friend-carol" + RecordSerializer.accountZoneID.zoneName, bobZone.zoneName, carolZone.zoneName ]) - let carol = spy.decisions.first { $0.zoneID.zoneName == "friend-carol" } + let carol = spy.decisions.first { $0.zoneID.zoneName == carolZone.zoneName } #expect(carol?.scope == 1) - #expect(carol?.zoneID.ownerName == "_carol-owner") + #expect(carol?.zoneID.ownerName == "_carol") // Fan-out no longer touches per-game Player rows. #expect(fetchPlayerNames(authorID: authorID, in: persistence).isEmpty) diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -858,9 +858,6 @@ struct RecordSerializerTests { let existing = FriendEntity(context: ctx) existing.authorID = "_bob" existing.pairKey = "k-existing" - existing.friendZoneName = "friend-k-existing" - existing.friendZoneOwnerName = CKCurrentUserDefaultName - existing.databaseScope = 0 existing.isBlocked = false existing.createdAt = Date() try ctx.save() @@ -882,6 +879,67 @@ struct RecordSerializerTests { #expect(rows.first?.pairKey == "k-existing") } + @Test("applyDecisionRecord(.block) is versioned: unblock clears, stale re-block ignored") + @MainActor func applyDecisionBlockUnblockVersioned() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + let existing = FriendEntity(context: ctx) + existing.authorID = "_bob" + existing.pairKey = "k" + existing.createdAt = Date() + try ctx.save() + + func blockDecision(_ payload: String, version: Int64) -> CKRecord { + RecordSerializer.decisionRecord( + kind: RecordSerializer.blockDecisionKind, + key: "_bob", + payload: payload, + zone: RecordSerializer.accountZoneID, + version: version + ) + } + + // Block at v1. + #expect(RecordSerializer.applyDecisionRecord( + blockDecision("1", version: 1), to: ctx, localAuthorID: "_alice" + )) + #expect(existing.isBlocked == true) + #expect(existing.blockVersion == 1) + + // Unblock at v2 supersedes it. + #expect(RecordSerializer.applyDecisionRecord( + blockDecision("0", version: 2), to: ctx, localAuthorID: "_alice" + )) + #expect(existing.isBlocked == false) + #expect(existing.blockVersion == 2) + + // A stale re-block at v1 is rejected — it can't resurrect the block. + #expect(!RecordSerializer.applyDecisionRecord( + blockDecision("1", version: 1), to: ctx, localAuthorID: "_alice" + )) + #expect(existing.isBlocked == false) + #expect(existing.blockVersion == 2) + } + + @Test("applyDecisionRecord(.name) rejects a friend name seen at shared scope") + @MainActor func applyNameDecisionRejectsSharedScope() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + // A friend's name only arrives via the inbox we own (scope 0). The same + // record seen at scope 1 — the friend's own inbox — is not the channel + // they tell us their name through, so it is dropped. + let record = nameDecisionRecord( + subject: "_bob", name: "Brandon", version: 1, + zone: friendZoneID(local: "_alice", remote: "_bob") + ) + let wrote = RecordSerializer.applyDecisionRecord( + record, to: ctx, localAuthorID: "_alice", databaseScope: 1 + ) + #expect(!wrote) + } + @Test("applyDecisionRecord ignores unknown kinds") @MainActor func applyDecisionIgnoresUnknownKind() throws { let persistence = makeTestPersistence() @@ -964,9 +1022,6 @@ struct RecordSerializerTests { let existing = FriendEntity(context: ctx) existing.authorID = "_bob" existing.pairKey = pairKey - existing.friendZoneName = FriendZone.zoneName(pairKey: pairKey) - existing.friendZoneOwnerName = CKCurrentUserDefaultName - existing.databaseScope = 0 existing.createdAt = Date() try ctx.save() @@ -992,9 +1047,6 @@ struct RecordSerializerTests { let existing = FriendEntity(context: ctx) existing.authorID = "_bob" existing.pairKey = pairKey - existing.friendZoneName = FriendZone.zoneName(pairKey: pairKey) - existing.friendZoneOwnerName = CKCurrentUserDefaultName - existing.databaseScope = 0 existing.createdAt = Date() existing.displayName = "Brandon" existing.displayNameVersion = 3 @@ -1015,15 +1067,17 @@ struct RecordSerializerTests { #expect(existing.displayNameVersion == 4) } - @Test("applyDecisionRecord(.name) resurrects a friendship from its zone") + @Test("applyDecisionRecord(.name) resurrects a friendship from our inbox") @MainActor func applyNameDecisionResurrectsFriend() throws { let persistence = makeTestPersistence() let ctx = persistence.viewContext let zone = friendZoneID(local: "_alice", remote: "_bob") + // A friend's name arrives via the inbox we own (private engine, + // scope 0). let record = nameDecisionRecord(subject: "_bob", name: "Brandon", version: 2, zone: zone) let wrote = RecordSerializer.applyDecisionRecord( - record, to: ctx, localAuthorID: "_alice", databaseScope: 1 + record, to: ctx, localAuthorID: "_alice", databaseScope: 0 ) #expect(wrote) @@ -1032,9 +1086,6 @@ struct RecordSerializerTests { let friend = try ctx.fetch(req).first #expect(friend?.displayName == "Brandon") #expect(friend?.pairKey == FriendZone.pairKey("_alice", "_bob")) - #expect(friend?.friendZoneName == zone.zoneName) - #expect(friend?.friendZoneOwnerName == "_zone-owner") - #expect(friend?.databaseScope == 1) #expect(friend?.isBlocked == false) } @@ -1087,9 +1138,6 @@ struct RecordSerializerTests { let blocked = FriendEntity(context: ctx) blocked.authorID = "_bob" blocked.pairKey = pairKey - blocked.friendZoneName = FriendZone.zoneName(pairKey: pairKey) - blocked.friendZoneOwnerName = CKCurrentUserDefaultName - blocked.databaseScope = 0 blocked.isBlocked = true blocked.createdAt = Date() try ctx.save() @@ -1135,9 +1183,6 @@ struct RecordSerializerTests { let friend = FriendEntity(context: ctx) friend.authorID = authorID friend.pairKey = pairKey - friend.friendZoneName = FriendZone.zoneName(pairKey: pairKey) - friend.friendZoneOwnerName = CKCurrentUserDefaultName - friend.databaseScope = 0 friend.createdAt = Date() try ctx.save() return friend diff --git a/Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift b/Tests/Unit/Sync/FriendControllerNicknameReplayTests.swift @@ -15,9 +15,6 @@ struct FriendControllerNicknameReplayTests { let friend = FriendEntity(context: ctx) friend.authorID = "_bob" friend.pairKey = FriendZone.pairKey("_alice", "_bob") - friend.friendZoneName = FriendZone.zoneName(pairKey: friend.pairKey!) - friend.friendZoneOwnerName = CKCurrentUserDefaultName - friend.databaseScope = 0 friend.createdAt = Date() try ctx.save() @@ -64,9 +61,6 @@ struct FriendControllerNicknameReplayTests { let friend = FriendEntity(context: ctx) friend.authorID = "_bob" friend.pairKey = FriendZone.pairKey("_alice", "_bob") - friend.friendZoneName = FriendZone.zoneName(pairKey: friend.pairKey!) - friend.friendZoneOwnerName = CKCurrentUserDefaultName - friend.databaseScope = 0 friend.createdAt = Date() friend.nickname = "Robert" friend.nicknameVersion = 2 diff --git a/Tests/Unit/Sync/FriendModelTests.swift b/Tests/Unit/Sync/FriendModelTests.swift @@ -21,18 +21,12 @@ struct FriendModelTests { let active = FriendEntity(context: ctx) active.authorID = "_active" active.pairKey = "k1" - active.friendZoneName = "friend-k1" - active.friendZoneOwnerName = CKCurrentUserDefaultName - active.databaseScope = 0 active.isBlocked = false active.createdAt = Date() let blocked = FriendEntity(context: ctx) blocked.authorID = "_blocked" blocked.pairKey = "k2" - blocked.friendZoneName = "friend-k2" - blocked.friendZoneOwnerName = CKCurrentUserDefaultName - blocked.databaseScope = 1 blocked.isBlocked = true blocked.createdAt = Date() try ctx.save() @@ -43,6 +37,29 @@ struct FriendModelTests { #expect(result.map { $0.authorID } == ["_active"]) } + @Test("hidden games are excluded by the game-list predicate") + func hiddenGamesExcluded() throws { + let persistence = makeTestPersistence() + let ctx = persistence.viewContext + + for (title, hidden) in [("visible", false), ("hidden", true)] { + let game = GameEntity(context: ctx) + game.id = UUID() + game.title = title + game.puzzleSource = "" + game.createdAt = Date() + game.updatedAt = Date() + game.isHidden = hidden + } + try ctx.save() + + // Exactly the predicate used by GameListView's games fetch — block hides + // a collaborator's games rather than leaving them, and unblock reveals. + let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") + req.predicate = NSPredicate(format: "isHidden == NO") + #expect(try ctx.fetch(req).map { $0.title } == ["visible"]) + } + @Test("pending-status predicate and pingRecordName dedup work") func invitePredicates() throws { let persistence = makeTestPersistence() @@ -141,7 +158,7 @@ struct FriendModelTests { #expect(stale == [ping.recordName]) } - @Test("knownZones predicate excludes a blocked friend zone") + @Test("outbox enumeration excludes a blocked friend") func blockedFriendExcludedFromKnownZones() throws { let persistence = makeTestPersistence() let ctx = persistence.viewContext @@ -150,18 +167,20 @@ struct FriendModelTests { let f = FriendEntity(context: ctx) f.authorID = "_\(suffix)" f.pairKey = suffix - f.friendZoneName = "friend-\(suffix)" - f.friendZoneOwnerName = "_owner" - f.databaseScope = 1 f.isBlocked = blocked f.createdAt = Date() } try ctx.save() - // Exactly the predicate used by SyncEngine.knownZones. + // The scope-1 (outbox) enumeration in CloudZones skips blocked friends + // and derives the zone from pairKey + authorID. let req = NSFetchRequest<FriendEntity>(entityName: "FriendEntity") - req.predicate = NSPredicate(format: "databaseScope == %d AND isBlocked == NO", 1) - #expect(try ctx.fetch(req).map { $0.friendZoneName } == ["friend-ok"]) + req.predicate = NSPredicate(format: "isBlocked == NO") + let zoneNames = try ctx.fetch(req).compactMap { friend -> String? in + guard let pairKey = friend.pairKey, let authorID = friend.authorID else { return nil } + return FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: authorID).zoneName + } + #expect(zoneNames == [FriendZone.zoneName(pairKey: "ok")]) } @Test("invites are scoped by inviterAuthorID for block cleanup") diff --git a/Tests/Unit/Sync/FriendZoneTests.swift b/Tests/Unit/Sync/FriendZoneTests.swift @@ -1,3 +1,4 @@ +import CloudKit import Foundation import Testing @@ -57,6 +58,31 @@ struct FriendZoneTests { #expect(!FriendZone.isOwner(localAuthorID: small, remoteAuthorID: small)) } + @Test("inbox and outbox share the pair's zone name but differ by owner") + func mailboxDerivation() { + let pairKey = FriendZone.pairKey("_alice", "_bob") + let inbox = FriendZone.inboxZoneID(pairKey: pairKey) + let outbox = FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: "_bob") + // Both mailboxes carry the same `friend-<pairKey>` name... + #expect(inbox.zoneName == FriendZone.zoneName(pairKey: pairKey)) + #expect(outbox.zoneName == inbox.zoneName) + // ...and are distinguished only by owner: our inbox is ours; the outbox + // is the friend's inbox, owned by the friend. + #expect(inbox.ownerName == CKCurrentUserDefaultName) + #expect(outbox.ownerName == "_bob") + #expect(inbox != outbox) + } + + @Test("a pair's two mailboxes agree on the zone name that binds them") + func mailboxSymmetry() { + let pairKey = FriendZone.pairKey("_alice", "_bob") + // Alice's outbox to Bob is the zone Bob owns; on Bob's device that same + // zone is his inbox. They agree on the zone name — the pairwise binding. + let aliceOutboxToBob = FriendZone.outboxZoneID(pairKey: pairKey, friendAuthorID: "_bob") + let bobInbox = FriendZone.inboxZoneID(pairKey: pairKey) + #expect(aliceOutboxToBob.zoneName == bobInbox.zoneName) + } + @Test("BootstrapPayload round-trips through its string encoding") func bootstrapPayloadRoundTrip() { let payload = FriendZone.BootstrapPayload( diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -196,9 +196,6 @@ struct SessionMonitorTests { let friend = FriendEntity(context: ctx) friend.authorID = Self.alice friend.pairKey = "pair-alice" - friend.friendZoneName = "friend-pair-alice" - friend.friendZoneOwnerName = "_owner" - friend.databaseScope = 0 friend.createdAt = Date() friend.nickname = "Mum" try ctx.save()