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:
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() }