crossmate

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

commit c0d703108be78ad311939912ba195c9d37ee6cd6
parent 91dddf14ebf533fde673e2669176daa6788a0afd
Author: Michael Camilleri <[email protected]>
Date:   Thu, 18 Jun 2026 11:31:16 +0900

Store public-link ticket state in the Ping payload

This commit keeps counted public-link tickets on the existing Ping
schema by encoding their state into the generic 'payload' string. New
links no longer need separate CloudKit fields for the ticket version,
remaining seat count, or claimed author IDs, avoiding schema creation
failures when a counted ticket starts with no claims.

Ticket consumption now decodes that payload, treats an already-claimed
CloudKit user as idempotent across devices, and saves the decremented
payload under CloudKit's optimistic lock. A ticket without a payload
remains the old one-shot form and is consumed by deletion.

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

Diffstat:
MCrossmate/Sync/ShareController.swift | 94+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
1 file changed, 54 insertions(+), 40 deletions(-)

diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -10,18 +10,22 @@ final class ShareController { private static let zoneWideShareRecordName = "cloudkit.zoneshare" static let maximumPeoplePerPuzzle = 3 private static var maximumInviteesPerPuzzle: Int { maximumPeoplePerPuzzle - 1 } - private static let ticketVersionField = "version" - private static let ticketRemainingSeatsField = "remainingSeats" - private static let ticketClaimedAuthorIDsField = "claimedAuthorIDs" - private static let countedTicketVersion: Int64 = 2 + private static let ticketPayloadField = "payload" + private static let countedTicketVersion = 2 + + private struct TicketPayload: Codable { + var version: Int + var remainingSeats: Int + var claimedAuthorIDs: [String] + } /// The seat ticket for public-link sharing: a `ticket`-kind Ping the owner - /// mints into the game zone alongside the link. Current tickets carry a - /// remaining-seat count and joiners consume a seat by saving a decremented - /// record under CloudKit's optimistic lock; legacy one-seat tickets had no - /// count and are still consumed by deletion. The `ticket` kind is unknown - /// to `PingKind`, so `Ping.parseRecord` drops the record everywhere Pings - /// are surfaced. + /// mints into the game zone alongside the link. Current tickets carry their + /// remaining-seat count in the existing `payload` string and joiners consume + /// a seat by saving a decremented record under CloudKit's optimistic lock; + /// legacy one-seat tickets had no count and are still consumed by deletion. + /// The `ticket` kind is unknown to `PingKind`, so `Ping.parseRecord` drops + /// the record everywhere Pings are surfaced. private static let ticketPingKind = "ticket" private static func ticketRecordName(for gameID: UUID) -> String { "ticket-\(gameID.uuidString)" @@ -672,9 +676,14 @@ final class ShareController { } ticket["kind"] = Self.ticketPingKind as CKRecordValue ticket["authorID"] = try await container.userRecordID().recordName as CKRecordValue - ticket[Self.ticketVersionField] = Self.countedTicketVersion as CKRecordValue - ticket[Self.ticketRemainingSeatsField] = Int64(seats) as CKRecordValue - ticket[Self.ticketClaimedAuthorIDsField] = claimedAuthorIDs.sorted() as CKRecordValue + try Self.setTicketPayload( + TicketPayload( + version: Self.countedTicketVersion, + remainingSeats: seats, + claimedAuthorIDs: Self.normalizedClaimedAuthorIDs(claimedAuthorIDs) + ), + on: ticket + ) do { _ = try await container.privateCloudDatabase.save(ticket) } catch let error as CKError where error.code == .serverRecordChanged { @@ -683,9 +692,14 @@ final class ShareController { } serverTicket["kind"] = Self.ticketPingKind as CKRecordValue serverTicket["authorID"] = try await container.userRecordID().recordName as CKRecordValue - serverTicket[Self.ticketVersionField] = Self.countedTicketVersion as CKRecordValue - serverTicket[Self.ticketRemainingSeatsField] = Int64(seats) as CKRecordValue - serverTicket[Self.ticketClaimedAuthorIDsField] = claimedAuthorIDs.sorted() as CKRecordValue + try Self.setTicketPayload( + TicketPayload( + version: Self.countedTicketVersion, + remainingSeats: seats, + claimedAuthorIDs: Self.normalizedClaimedAuthorIDs(claimedAuthorIDs) + ), + on: serverTicket + ) _ = try await container.privateCloudDatabase.save(serverTicket) } } @@ -709,12 +723,7 @@ final class ShareController { } } - var claimedAuthorIDs = Self.claimedAuthorIDs(in: record) - if claimedAuthorIDs.contains(myRecordName) { - return true - } - - guard let remaining = Self.remainingSeats(in: record) else { + guard var payload = Self.ticketPayload(in: record) else { do { try await database.deleteRecord(withID: ticketID) return true @@ -722,11 +731,18 @@ final class ShareController { return false } } - guard remaining > 0 else { return false } + + var claimedAuthorIDs = Set(payload.claimedAuthorIDs) + if claimedAuthorIDs.contains(myRecordName) { + return true + } + + guard payload.remainingSeats > 0 else { return false } claimedAuthorIDs.insert(myRecordName) - record[Self.ticketRemainingSeatsField] = Int64(remaining - 1) as CKRecordValue - record[Self.ticketClaimedAuthorIDsField] = claimedAuthorIDs.sorted() as CKRecordValue - record[Self.ticketVersionField] = Self.countedTicketVersion as CKRecordValue + payload.version = Self.countedTicketVersion + payload.remainingSeats -= 1 + payload.claimedAuthorIDs = Self.normalizedClaimedAuthorIDs(Array(claimedAuthorIDs)) + try Self.setTicketPayload(payload, on: record) do { _ = try await database.save(record) return true @@ -737,24 +753,22 @@ final class ShareController { return false } - private static func remainingSeats(in ticket: CKRecord) -> Int? { - if let value = ticket[ticketRemainingSeatsField] as? Int64 { - return Int(value) - } - if let value = ticket[ticketRemainingSeatsField] as? Int { - return value + private static func setTicketPayload(_ payload: TicketPayload, on ticket: CKRecord) throws { + let data = try JSONEncoder().encode(payload) + ticket[ticketPayloadField] = String(decoding: data, as: UTF8.self) as CKRecordValue + } + + private static func ticketPayload(in ticket: CKRecord) -> TicketPayload? { + if let value = ticket[ticketPayloadField] as? String, + let data = value.data(using: .utf8), + let payload = try? JSONDecoder().decode(TicketPayload.self, from: data) { + return payload } return nil } - private static func claimedAuthorIDs(in ticket: CKRecord) -> Set<String> { - if let values = ticket[ticketClaimedAuthorIDsField] as? [String] { - return Set(values) - } - if let values = ticket[ticketClaimedAuthorIDsField] as? NSArray { - return Set(values.compactMap { $0 as? String }) - } - return [] + private static func normalizedClaimedAuthorIDs(_ authorIDs: [String]) -> [String] { + authorIDs.filter { !$0.isEmpty }.sorted() } private func disablePublicLinkIfNeeded(_ share: CKShare, for gameID: UUID) async throws {