crossmate

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

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:
MCrossmate/Sync/ShareController.swift | 58+++++++++++++++++++++++++++++++++++-----------------------
MCrossmate/Views/GameShareItem.swift | 13++++++-------
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." } }