crossmate

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

commit 649f86acf78e6a84324da02a98411cee6dc85760
parent e868c6aaee4724a9e52849984da468e652d0a318
Author: Michael Camilleri <[email protected]>
Date:   Mon, 27 Apr 2026 08:47:55 +0900

Switch share sheet to ShareLink

This commit uses a ShareLink backed by CKShareTransferRepresentation, which
lets SwiftUI manage the presentation natively. ShareController.prepareShare now
only creates the zone and returns an unsaved CKShare; the system saves the
share when the user submits participants. The share's record name is recorded
via the new persistShareName method, called from the transfer representation's
preparation handler.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++----
MCrossmate/Sync/ShareController.swift | 35++++++++++++++++++++---------------
DCrossmate/Views/CloudSharingView.swift | 62--------------------------------------------------------------
MCrossmate/Views/GameListView.swift | 35+++++++++++++++++++++--------------
ACrossmate/Views/GameShareItem.swift | 25+++++++++++++++++++++++++
MCrossmate/Views/PuzzleView.swift | 28+++++++++++++---------------
6 files changed, 83 insertions(+), 110 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */; }; + 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ABB557BA10CBE9909056882 /* GameShareItem.swift */; }; 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B331CC55827FEF3420ABCE /* PlayerSession.swift */; }; 905D79CFE454D2E4374544C7 /* GameStoreSnapshotPruningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F61F35F4B4AEF51C02276A3 /* GameStoreSnapshotPruningTests.swift */; }; 9789150602A3321D2E1E7E81 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0BF60C84D92A9024AC1A53FC /* Media.xcassets */; }; @@ -69,7 +70,6 @@ DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14F2AC5C3B50F4178859E9AC /* CrossmateApp.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; - EE7DE19BE6F788B8D3D60DF2 /* CloudSharingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62B3141EBEDC2D040DDBC0B /* CloudSharingView.swift */; }; F2BE3AA7211847AD0CCF1202 /* MoveBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */; }; F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */; }; F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */; }; @@ -113,6 +113,7 @@ 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBuffer.swift; sourceTree = "<group>"; }; 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLogTests.swift; sourceTree = "<group>"; }; 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; + 5ABB557BA10CBE9909056882 /* GameShareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameShareItem.swift; sourceTree = "<group>"; }; 5C63A148D98E2D37EABF2CF5 /* sample.xd */ = {isa = PBXFileReference; path = sample.xd; sourceTree = "<group>"; }; 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; @@ -152,7 +153,6 @@ D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; }; DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; - E62B3141EBEDC2D040DDBC0B /* CloudSharingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSharingView.swift; sourceTree = "<group>"; }; E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; }; E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; @@ -277,9 +277,9 @@ 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */, C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */, F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, - E62B3141EBEDC2D040DDBC0B /* CloudSharingView.swift */, E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */, 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */, + 5ABB557BA10CBE9909056882 /* GameShareItem.swift */, D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */, CAB4BB9E160C3A59C653E7A9 /* GridView.swift */, 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */, @@ -465,7 +465,6 @@ 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */, - EE7DE19BE6F788B8D3D60DF2 /* CloudSharingView.swift in Sources */, B94919176DEC6EC31637B037 /* ClueList.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, @@ -474,6 +473,7 @@ 818B1F2693962832BE14578E /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, DB74ED1E2DFEBEC951E10C8E /* GamePlayerColorStore.swift in Sources */, + 849970A21D62C34EC382A27E /* GameShareItem.swift in Sources */, D58980B92C99122C368D4216 /* GameStore.swift in Sources */, C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -7,7 +7,7 @@ import Foundation /// existing shares on re-present, and letting participants leave a shared game. @MainActor final class ShareController { - private let container: CKContainer + let container: CKContainer private let persistence: PersistenceController private let syncEngine: SyncEngine @@ -23,9 +23,12 @@ final class ShareController { self.syncEngine = syncEngine } - /// Returns the `CKShare` and container needed to present - /// `UICloudSharingController`. Creates the share on first call; returns - /// the existing one on subsequent calls. + /// Returns the `CKShare` and container for `UICloudSharingController`'s + /// preparation handler. For a first-time share, the returned share is + /// *unsaved* — `UICloudSharingController` saves it when the user submits + /// participants. Call `persistShareName(_:for:)` from the controller's + /// `didSaveShare` delegate callback to record the saved share's name. + /// For an existing share, the saved share is fetched and returned. func prepareShare(for gameID: UUID) async throws -> (CKShare, CKContainer) { let ctx = persistence.viewContext let request = NSFetchRequest<GameEntity>(entityName: "GameEntity") @@ -38,7 +41,6 @@ final class ShareController { throw ShareError.notAnOwner } - // Return an existing share so the controller can update participants. if let existingName = entity.ckShareRecordName { return try await fetchExistingShare( recordName: existingName, @@ -61,20 +63,23 @@ final class ShareController { op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) } self.container.privateCloudDatabase.add(op) } + let share = CKShare(recordZoneID: zoneID) share.publicPermission = .none + return (share, container) + } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in - let op = CKModifyRecordsOperation(recordsToSave: [share], recordIDsToDelete: nil) - op.qualityOfService = .userInitiated - op.modifyRecordsResultBlock = { result in cont.resume(with: result) } - self.container.privateCloudDatabase.add(op) - } - - entity.ckShareRecordName = share.recordID.recordName + /// Records the share's CloudKit record name on the local entity so future + /// invocations of `prepareShare` fetch the existing share. Idempotent. + func persistShareName(_ recordName: String, for gameID: UUID) 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 else { return } + guard entity.ckShareRecordName != recordName else { return } + entity.ckShareRecordName = recordName try ctx.save() - - return (share, container) } /// Removes the current user's participation from a shared game and deletes diff --git a/Crossmate/Views/CloudSharingView.swift b/Crossmate/Views/CloudSharingView.swift @@ -1,62 +0,0 @@ -import CloudKit -import SwiftUI -import UIKit - -/// `UIViewControllerRepresentable` wrapping `UICloudSharingController`. -/// Uses the preparation-handler initialiser so the share is created lazily -/// via `ShareController` (which needs a network round-trip). -struct CloudSharingView: UIViewControllerRepresentable { - let gameID: UUID - let title: String - let shareController: ShareController - var onDone: (() -> Void)? - - func makeUIViewController(context: Context) -> UICloudSharingController { - let ctrl = UICloudSharingController { [shareController, gameID] (_, completion) in - Task { @MainActor in - do { - let (share, container) = try await shareController.prepareShare(for: gameID) - completion(share, container, nil) - } catch { - completion(nil, nil, error) - } - } - } - ctrl.delegate = context.coordinator - ctrl.availablePermissions = [.allowReadWrite, .allowPrivate] - return ctrl - } - - func updateUIViewController(_ uiViewController: UICloudSharingController, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(title: title, onDone: onDone) - } - - final class Coordinator: NSObject, UICloudSharingControllerDelegate { - let title: String - let onDone: (() -> Void)? - - init(title: String, onDone: (() -> Void)?) { - self.title = title - self.onDone = onDone - } - - func itemTitle(for csc: UICloudSharingController) -> String? { title } - - func cloudSharingController( - _ csc: UICloudSharingController, - failedToSaveShareWithError error: Error - ) { - onDone?() - } - - func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { - onDone?() - } - - func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { - onDone?() - } - } -} diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -17,7 +17,6 @@ struct GameListView: View { @State private var showingSettings = false @State private var deleteTarget: GameSummary? @State private var resignTarget: GameSummary? - @State private var shareTarget: GameSummary? @State private var leaveTarget: GameSummary? @State private var leaveError: Error? @@ -89,15 +88,6 @@ struct GameListView: View { .sheet(isPresented: $showingNewGame) { NewGameSheet(store: store) } - .sheet(item: $shareTarget) { game in - CloudSharingView( - gameID: game.id, - title: game.title, - shareController: shareController - ) { - shareTarget = nil - } - } .alert("Resign Puzzle?", isPresented: .init( get: { resignTarget != nil }, set: { if !$0 { resignTarget = nil } } @@ -149,8 +139,8 @@ struct GameListView: View { private func rowView(for game: GameSummary) -> some View { GameRowView( game: game, + shareController: shareController, onResume: { navigationPath.append(game.id) }, - onShare: { shareTarget = game }, onLeave: { leaveTarget = game }, onResign: { resignTarget = game }, onDelete: { deleteTarget = game } @@ -181,8 +171,8 @@ struct GameListView: View { private struct GameRowView: View { let game: GameSummary + let shareController: ShareController var onResume: () -> Void = {} - var onShare: () -> Void = {} var onLeave: () -> Void = {} var onResign: () -> Void = {} var onDelete: () -> Void = {} @@ -215,9 +205,26 @@ private struct GameRowView: View { } } Spacer() + if game.isOwned { + ShareLink( + item: GameShareItem( + gameID: game.id, + title: game.title, + container: shareController.container, + shareController: shareController + ), + preview: SharePreview(game.title) + ) { + Image(systemName: "square.and.arrow.up") + .font(.body) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .tint(.secondary) + .compositingGroup() + } Menu { - Button { onShare() } label: { Label("Share", systemImage: "square.and.arrow.up") } - .disabled(!game.isOwned) Button { onLeave() } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") } .disabled(!(!game.isOwned && game.isShared)) Button { onResume() } label: { Label("Resume", systemImage: "square.and.pencil") } diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift @@ -0,0 +1,25 @@ +import CloudKit +import CoreTransferable +import Foundation + +/// `Transferable` item that lets `ShareLink` drive `UICloudSharingController` +/// natively via `CKShareTransferRepresentation`. SwiftUI handles presentation, +/// so we don't reach into UIKit's view hierarchy to present anything. +struct GameShareItem: Transferable { + let gameID: UUID + let title: String + let container: CKContainer + let shareController: ShareController + + static var transferRepresentation: some TransferRepresentation { + CKShareTransferRepresentation { (item: GameShareItem) in + .prepareShare(container: item.container) { + let (share, _) = try await item.shareController.prepareShare(for: item.gameID) + // Zone-wide shares have a fixed recordName ("cloudkit.zoneshare"), + // so persisting now is safe — the system saves under that same name. + try? await item.shareController.persistShareName(share.recordID.recordName, for: item.gameID) + return share + } + } + } +} diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -11,7 +11,6 @@ struct PuzzleView: View { @State private var renameDraft = "" @State private var showSuccessScreen = false @State private var showErrorsAlert = false - @State private var isPresentingShareSheet = false @State private var isConfirmingLeave = false @State private var leaveError: String? @State private var isRevokedBannerDismissed = false @@ -184,10 +183,20 @@ struct PuzzleView: View { } .disabled(!(session.mutator.isShared && !session.mutator.isOwned) || shareController == nil) - Button("Share Game") { - isPresentingShareSheet = true + if let shareController { + ShareLink( + item: GameShareItem( + gameID: session.mutator.gameID, + title: session.puzzle.title, + container: shareController.container, + shareController: shareController + ), + preview: SharePreview(session.puzzle.title) + ) { + Text("Share Game") + } + .disabled(!session.mutator.isOwned) } - .disabled(!session.mutator.isOwned || shareController == nil) } } label: { Label("Players", systemImage: "person.2") @@ -220,17 +229,6 @@ struct PuzzleView: View { .sheet(isPresented: $showSuccessScreen) { SuccessScreen(session: session) } - .sheet(isPresented: $isPresentingShareSheet) { - if let shareController { - CloudSharingView( - gameID: session.mutator.gameID, - title: session.puzzle.title, - shareController: shareController - ) { - isPresentingShareSheet = false - } - } - } .alert("Leave Puzzle?", isPresented: $isConfirmingLeave) { Button("Leave", role: .destructive) { Task { await leaveSharedGame() }