commit cceaeae51810ad4f7a86b326597ebe3d3507c6ec
parent a43aae8727779253a7c11e691dcc58047023e9e5
Author: Michael Camilleri <[email protected]>
Date: Sat, 13 Jun 2026 09:03:39 +0900
Gate share links on seat capacity instead of a global switch
This commit retires the isPublicLinkSharingEnabled flag that disabled
public links wholesale and gates the link path on the puzzle's seat
instead: an owner can create and share a link while the invitee seat is
open, and the share sheet hides link controls once the seat is taken (a
pending invite counts). A full puzzle resolves to no link — any
lingering public permission is revoked so the old URL stops admitting
joiners — and a friend invite that commits the last seat revokes public
access in the same save. The link save-conflict recovery path now
re-checks capacity against the server share, since the conflicting save
may be a sibling device committing the seat. This activates the seat
ticket protocol, which has been dormant since its introduction.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat:
2 files changed, 41 insertions(+), 30 deletions(-)
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -9,7 +9,6 @@ import Foundation
final class ShareController {
private static let zoneWideShareRecordName = "cloudkit.zoneshare"
static let maximumPeoplePerPuzzle = 2
- static let isPublicLinkSharingEnabled = false
private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 }
/// The seat ticket for public-link sharing: a `ticket`-kind Ping the owner
@@ -40,7 +39,6 @@ final class ShareController {
case notAnOwner
case invalidGameRecord
case missingShareURL
- case shareLinksDisabled(maxPeople: Int)
case collaborationLimitReached(maxPeople: Int)
var errorDescription: String? {
@@ -55,8 +53,6 @@ final class ShareController {
"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."
}
@@ -93,9 +89,6 @@ 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)
guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else {
throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
@@ -134,6 +127,7 @@ final class ShareController {
)
try enforceInviteCapacity(on: share, adding: userRecordName)
try await addParticipantIfNeeded(userRecordName, to: share)
+ revokePublicAccessIfFull(of: share)
let saved: CKShare
do {
saved = try await saveShareForLink(share, for: gameID)
@@ -178,6 +172,14 @@ final class ShareController {
}
}
+ /// A full puzzle offers no public link: once an invite commits the last
+ /// seat, the same save revokes any outstanding link so it stops admitting
+ /// joiners at the CloudKit level.
+ private func revokePublicAccessIfFull(of share: CKShare) {
+ guard Self.inviteeCount(in: share) >= Self.maximumInviteesPerPuzzle else { return }
+ share.publicPermission = .none
+ }
+
private static func inviteeCount(in share: CKShare) -> Int {
share.participants.filter { participant in
participant.role != .owner
@@ -238,6 +240,7 @@ final class ShareController {
configureShare(share, title: entity.title, publicPermission: nil)
try enforceInviteCapacity(on: share, adding: userRecordName)
try await addParticipantIfNeeded(userRecordName, to: share)
+ revokePublicAccessIfFull(of: share)
return try await saveShareForLink(share, for: gameID)
}
@@ -264,11 +267,7 @@ 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
+ return try await publicLinkURL(from: share, for: gameID)
} catch let error as CKError where isMissingShare(error) {
return nil
}
@@ -279,11 +278,7 @@ 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
+ return try await publicLinkURL(from: share, for: gameID)
} catch let error as CKError where error.code == .unknownItem {
entity.ckShareRecordName = nil
try ctx.save()
@@ -291,6 +286,19 @@ final class ShareController {
}
}
+ /// Resolves a fetched share to its live public link. A full puzzle has no
+ /// link to offer — any lingering public permission is revoked so the old
+ /// URL stops admitting joiners — and a share without public access
+ /// reports `nil` so the caller can offer to create a fresh link.
+ private func publicLinkURL(from share: CKShare, for gameID: UUID) async throws -> URL? {
+ guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else {
+ try await disablePublicLinkIfNeeded(share, for: gameID)
+ return nil
+ }
+ guard share.publicPermission != .none else { return nil }
+ return share.url
+ }
+
/// Whether the puzzle's invitee seat is already taken, per the share's
/// actual participant list (a pending invite counts — the seat is
/// committed once offered). Seeds the share sheet so a full game opens
@@ -571,13 +579,11 @@ final class ShareController {
title: String?,
publicPermission: CKShare.ParticipantPermission?
) -> CKShare {
- // 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 {
+ // `nil` leaves the existing public permission untouched — the
+ // friend-invite path uses it so re-saving a share doesn't disturb a
+ // link the owner created separately while the seat is still open.
+ if let publicPermission {
share.publicPermission = publicPermission
- } else if !Self.isPublicLinkSharingEnabled {
- share.publicPermission = .none
}
share[CKShare.SystemFieldKey.title] = title as CKRecordValue?
return share
@@ -653,6 +659,12 @@ final class ShareController {
)
}
+ // The conflicting save may have been a sibling device committing the
+ // seat; re-check capacity against the server share before re-opening
+ // public access.
+ guard Self.inviteeCount(in: share) < Self.maximumInviteesPerPuzzle else {
+ throw ShareError.collaborationLimitReached(maxPeople: Self.maximumPeoplePerPuzzle)
+ }
configureShare(share, title: entity.title, publicPermission: .readWrite)
return try await saveShareForLink(share, for: gameID)
}
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -65,7 +65,7 @@ struct GameShareSheet: View {
.listRowBackground(Color.clear)
Section {
- if ShareController.isPublicLinkSharingEnabled {
+ if !isInviteLimitReached {
if let shareURL {
Button {
UIPasteboard.general.string = shareURL.absoluteString
@@ -99,7 +99,8 @@ struct GameShareSheet: View {
}
} else {
Label("Link Sharing Disabled", systemImage: "link.badge.plus")
- Text("Invite one Crossmate directly for this TestFlight.")
+ .foregroundStyle(.secondary)
+ Text("This game already has its crossmate.")
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -183,12 +184,10 @@ struct GameShareSheet: View {
}
private var headerSubtitle: String {
- if ShareController.isPublicLinkSharingEnabled {
- "Any iCloud user with the link will be able to join your game and collaborate."
- } else if isInviteLimitReached {
- "This game already has its Crossmate. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players."
+ if isInviteLimitReached {
+ "This game already has its crossmate. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players."
} else {
- "Invite one Crossmate to join your game and collaborate."
+ "Anyone with the link can join your game. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players."
}
}