commit 2e13827eba4c67baf94e70e4741cf6b8580b7ef6
parent 7dfc576bc1d9a6a0b8523fe077856efc014cebd7
Author: Michael Camilleri <[email protected]>
Date: Fri, 12 Jun 2026 18:54:52 +0900
Limit shared puzzles to one invitee
This commit caps collaboration at two people per puzzle: the owner and
one invited crossmate. Direct friend invites now check the share's
participant list before adding another user, and the share UI stops
offering additional invites once the local session has sent one.
Public share links are disabled while the cap is active. Creating a new
link now fails with a localized sharing error, existing link controls
are hidden behind disabled copy in the share sheet, and any share save
revokes public access so old links stop admitting new participants once
the owner touches the share.
The accept path also handles stale public links that CloudKit may still
allow before Crossmate can inspect the share. After accepting and
discovering the shared zone, the app fetches the accepted CKShare,
leaves and deletes the local row if the puzzle is already over the cap,
and surfaces the same localized limit error instead of treating the join
as successful.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
5 files changed, 165 insertions(+), 44 deletions(-)
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -354,7 +354,8 @@ final class AppServices {
container: self.ckContainer,
syncEngine: syncEngine,
syncMonitor: self.syncMonitor,
- store: store
+ store: store,
+ shareController: shareController
)
self.importService = ImportService(store: store, driveMonitor: self.driveMonitor)
self.engagementHost = EngagementHost()
diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift
@@ -11,6 +11,7 @@ final class CloudService {
private let syncEngine: SyncEngine
private let syncMonitor: SyncMonitor
private let store: GameStore
+ private let shareController: ShareController
/// Fired after a successful share acceptance once the shared zone has been
/// fetched. Used to enqueue a `.join` ping so other collaborators are
@@ -21,12 +22,14 @@ final class CloudService {
container: CKContainer,
syncEngine: SyncEngine,
syncMonitor: SyncMonitor,
- store: GameStore
+ store: GameStore,
+ shareController: ShareController
) {
self.ckContainer = container
self.syncEngine = syncEngine
self.syncMonitor = syncMonitor
self.store = store
+ self.shareController = shareController
}
/// Fetches share metadata for a URL and joins via `acceptShare(metadata:)`.
@@ -82,6 +85,9 @@ final class CloudService {
let joinedGameID = store.joinedSharedGameIDs()
.subtracting(existingJoinedGameIDs)
.first
+ if let joinedGameID {
+ try await shareController.leaveShareIfParticipantLimitExceeded(gameID: joinedGameID)
+ }
NotificationCenter.default.post(
name: .cloudShareAcceptanceCompleted,
object: nil,
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -8,6 +8,9 @@ import Foundation
@MainActor
final class ShareController {
private static let zoneWideShareRecordName = "cloudkit.zoneshare"
+ static let maximumPeoplePerPuzzle = 2
+ static let isPublicLinkSharingEnabled = false
+ private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 }
let container: CKContainer
private let persistence: PersistenceController
@@ -19,12 +22,33 @@ final class ShareController {
/// `isShared` flag) can flip without waiting for the user to re-open.
var onShareSaved: (@MainActor (UUID) -> Void)?
- enum ShareError: Error {
+ enum ShareError: LocalizedError {
case gameNotFound
case invalidShareRecord
case notAnOwner
case invalidGameRecord
case missingShareURL
+ case shareLinksDisabled(maxPeople: Int)
+ case collaborationLimitReached(maxPeople: Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .gameNotFound:
+ "Game not found."
+ case .invalidShareRecord:
+ "Invalid share record."
+ case .notAnOwner:
+ "Only the puzzle owner can share this game."
+ case .invalidGameRecord:
+ "Invalid game record."
+ case .missingShareURL:
+ "CloudKit did not return a share URL."
+ case .shareLinksDisabled(let maxPeople):
+ "Link sharing is disabled while puzzles are limited to \(maxPeople) people."
+ case .collaborationLimitReached(let maxPeople):
+ "This puzzle already has the maximum of \(maxPeople) people."
+ }
+ }
}
init(
@@ -57,6 +81,9 @@ final class ShareController {
func createShareLink(for gameID: UUID) async throws -> URL {
syncMonitor?.recordStart("create share link")
do {
+ guard Self.isPublicLinkSharingEnabled else {
+ throw ShareError.shareLinksDisabled(maxPeople: Self.maximumPeoplePerPuzzle)
+ }
let share = try await prepareShareRecord(for: gameID, publicPermission: .readWrite)
let savedShare: CKShare
do {
@@ -75,11 +102,9 @@ final class ShareController {
}
/// Ensures the game's `CKShare` exists and adds `userRecordName` as a
- /// `.readWrite` participant, without changing an existing share's public
- /// permission (a brand-new share is created private, `.none`). Returns the
- /// share URL so the caller can hand it to the friend via an `.invite`
- /// Ping. Idempotent: re-inviting an already-added participant is a no-op
- /// re-save.
+ /// `.readWrite` participant. Returns the share URL so the caller can hand
+ /// it to the friend via an `.invite` Ping. Idempotent: re-inviting an
+ /// already-added participant is a no-op re-save.
func addFriendParticipant(
toGameID gameID: UUID,
userRecordName: String
@@ -91,6 +116,7 @@ final class ShareController {
publicPermission: .none,
reconfigureExistingPublicPermission: false
)
+ try enforceInviteCapacity(on: share, adding: userRecordName)
try await addParticipantIfNeeded(userRecordName, to: share)
let saved: CKShare
do {
@@ -124,6 +150,25 @@ final class ShareController {
share.addParticipant(participant)
}
+ private func enforceInviteCapacity(on share: CKShare, adding userRecordName: String) throws {
+ let already = share.participants.contains {
+ $0.userIdentity.userRecordID?.recordName == userRecordName
+ }
+ guard !already else { return }
+
+ let inviteeCount = Self.inviteeCount(in: share)
+ guard inviteeCount < Self.maximumInviteesPerPuzzle else {
+ throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
+ }
+ }
+
+ private static func inviteeCount(in share: CKShare) -> Int {
+ share.participants.filter { participant in
+ participant.role != .owner
+ && participant.acceptanceStatus != .removed
+ }.count
+ }
+
private func fetchParticipant(
forUserRecordName recordName: String
) async throws -> CKShare.Participant {
@@ -173,8 +218,9 @@ final class ShareController {
zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
)
}
- // Preserve whatever public permission the server share has.
+ // Keep the share's metadata current while applying the current link policy.
configureShare(share, title: entity.title, publicPermission: nil)
+ try enforceInviteCapacity(on: share, adding: userRecordName)
try await addParticipantIfNeeded(userRecordName, to: share)
return try await saveShareForLink(share, for: gameID)
}
@@ -202,6 +248,10 @@ final class ShareController {
)
entity.ckShareRecordName = share.recordID.recordName
try ctx.save()
+ guard Self.isPublicLinkSharingEnabled else {
+ try await disablePublicLinkIfNeeded(share, for: gameID)
+ return nil
+ }
return share.url
} catch let error as CKError where isMissingShare(error) {
return nil
@@ -213,6 +263,10 @@ final class ShareController {
recordName: existingName,
zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
)
+ guard Self.isPublicLinkSharingEnabled else {
+ try await disablePublicLinkIfNeeded(share, for: gameID)
+ return nil
+ }
return share.url
} catch let error as CKError where error.code == .unknownItem {
entity.ckShareRecordName = nil
@@ -348,6 +402,30 @@ final class ShareController {
try ctx.save()
}
+ /// Called immediately after accepting a share. If an old public link let
+ /// this account join a puzzle that is already at the TestFlight cap, leave
+ /// the share before the rest of the app treats the join as successful.
+ func leaveShareIfParticipantLimitExceeded(gameID: UUID) async throws {
+ let ctx = persistence.viewContext
+ let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ request.fetchLimit = 1
+ guard let entity = try ctx.fetch(request).first,
+ entity.databaseScope == 1 else { return }
+
+ let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
+ let shareID = CKRecord.ID(recordName: Self.zoneWideShareRecordName, zoneID: zoneID)
+ let record = try await container.sharedCloudDatabase.record(for: shareID)
+ guard let share = record as? CKShare,
+ Self.inviteeCount(in: share) > Self.maximumInviteesPerPuzzle
+ else { return }
+
+ try await leaveShare(gameID: gameID)
+ throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
+ }
+
// MARK: - Helpers
private func fetchExistingShare(
@@ -384,16 +462,24 @@ final class ShareController {
title: String?,
publicPermission: CKShare.ParticipantPermission?
) -> CKShare {
- // `nil` means "leave the existing public permission untouched" — used
- // by the friend-invite path so adding a private participant never
- // silently revokes a public link the user created earlier.
- if let publicPermission {
+ // When link sharing is enabled, `nil` leaves the existing public
+ // permission untouched. While disabled, every share save also revokes
+ // public access so old links stop admitting new participants.
+ if Self.isPublicLinkSharingEnabled, let publicPermission {
share.publicPermission = publicPermission
+ } else if !Self.isPublicLinkSharingEnabled {
+ share.publicPermission = .none
}
share[CKShare.SystemFieldKey.title] = title as CKRecordValue?
return share
}
+ private func disablePublicLinkIfNeeded(_ share: CKShare, for gameID: UUID) async throws {
+ guard share.publicPermission != .none else { return }
+ share.publicPermission = .none
+ _ = try await saveShareForLink(share, for: gameID)
+ }
+
private func saveShareForLink(_ share: CKShare, for gameID: UUID) async throws -> CKShare {
let savedRecord = try await container.privateCloudDatabase.save(share)
guard let savedShare = savedRecord as? CKShare else {
diff --git a/Crossmate/Views/FriendPickerView.swift b/Crossmate/Views/FriendPickerView.swift
@@ -19,6 +19,7 @@ struct FriendPickerView: View {
@State private var invitingAuthorID: String?
@State private var invitedAuthorIDs: Set<String> = []
+ @State private var isInviteLimitReached = false
@State private var errorMessage: String?
var body: some View {
@@ -72,7 +73,7 @@ struct FriendPickerView: View {
Spacer()
}
}
- .disabled(authorID.isEmpty || invitingAuthorID != nil || invited)
+ .disabled(authorID.isEmpty || invitingAuthorID != nil || invited || (isInviteLimitReached && !invited))
}
/// Maps the row's invite state to the avatar's animation phase. Sending
@@ -91,9 +92,19 @@ struct FriendPickerView: View {
defer { withAnimation(.snappy) { invitingAuthorID = nil } }
do {
try await inviteFriend(gameID, authorID)
- withAnimation(.snappy) { _ = invitedAuthorIDs.insert(authorID) }
+ withAnimation(.snappy) {
+ _ = invitedAuthorIDs.insert(authorID)
+ isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
+ }
} catch {
- errorMessage = String(describing: error)
+ if case ShareController.ShareError.collaborationLimitReached = error {
+ withAnimation(.snappy) { isInviteLimitReached = true }
+ }
+ errorMessage = describe(error)
}
}
+
+ private func describe(_ error: Error) -> String {
+ (error as? LocalizedError)?.errorDescription ?? String(describing: error)
+ }
}
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -23,6 +23,7 @@ struct GameShareSheet: View {
@State private var didCopy = false
@State private var invitingAuthorID: String?
@State private var invitedAuthorIDs: Set<String> = []
+ @State private var isInviteLimitReached = false
private var visibleFriends: Array<FetchedResults<FriendEntity>.Element> {
Array(friends.prefix(4))
@@ -45,7 +46,9 @@ struct GameShareSheet: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
- Text("Any iCloud user with the link will be able to join your game and collaborate.")
+ Text(ShareController.isPublicLinkSharingEnabled
+ ? "Any iCloud user with the link will be able to join your game and collaborate."
+ : "Invite one Crossmate to join your game and collaborate.")
.font(.body)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
@@ -64,36 +67,43 @@ struct GameShareSheet: View {
.listRowBackground(Color.clear)
Section {
- if let shareURL {
- Button {
- UIPasteboard.general.string = shareURL.absoluteString
- didCopy = true
- } label: {
- Label(didCopy ? "Copied" : "Copy Link", systemImage: didCopy ? "checkmark" : "doc.on.doc")
- }
+ if ShareController.isPublicLinkSharingEnabled {
+ if let shareURL {
+ Button {
+ UIPasteboard.general.string = shareURL.absoluteString
+ didCopy = true
+ } label: {
+ Label(didCopy ? "Copied" : "Copy Link", systemImage: didCopy ? "checkmark" : "doc.on.doc")
+ }
- ShareLink(item: shareURL) {
- Label("Send Link", systemImage: "square.and.arrow.up")
- }
- } else if isLoadingExistingLink {
- HStack {
- Label("Checking Link", systemImage: "link")
- Spacer()
- ProgressView()
- }
- } else {
- Button {
- Task { await createLink() }
- } label: {
+ ShareLink(item: shareURL) {
+ Label("Send Link", systemImage: "square.and.arrow.up")
+ }
+ } else if isLoadingExistingLink {
HStack {
- Label("Create Link", systemImage: "link")
- if isCreating {
- Spacer()
- ProgressView()
+ Label("Checking Link", systemImage: "link")
+ Spacer()
+ ProgressView()
+ }
+ } else {
+ Button {
+ Task { await createLink() }
+ } label: {
+ HStack {
+ Label("Create Link", systemImage: "link")
+ if isCreating {
+ Spacer()
+ ProgressView()
+ }
}
}
+ .disabled(isCreating || isLoadingExistingLink)
}
- .disabled(isCreating || isLoadingExistingLink)
+ } else {
+ Label("Link Sharing Disabled", systemImage: "link.badge.plus")
+ Text("Invite one Crossmate directly for this TestFlight.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
}
}
@@ -124,6 +134,7 @@ struct GameShareSheet: View {
.frame(width: 96, height: 88)
}
.buttonStyle(.plain)
+ .disabled(isInviteLimitReached)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 2)
@@ -185,7 +196,7 @@ struct GameShareSheet: View {
}
.frame(width: 108, height: 88)
}
- .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited)
+ .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || (isInviteLimitReached && !wasInvited))
}
/// Maps the row's invite state to the avatar's animation phase. Sending
@@ -205,8 +216,14 @@ struct GameShareSheet: View {
do {
try await inviteFriend(gameID, authorID)
- withAnimation(.snappy) { _ = invitedAuthorIDs.insert(authorID) }
+ withAnimation(.snappy) {
+ _ = invitedAuthorIDs.insert(authorID)
+ isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1
+ }
} catch {
+ if case ShareController.ShareError.collaborationLimitReached = error {
+ withAnimation(.snappy) { isInviteLimitReached = true }
+ }
errorMessage = describe(error)
}
}