commit 15df6991fabbf7db9efe7f49fbcd80e28fc0b1b8
parent a256e447d2e519bce849f36752e5183fd202c0be
Author: Michael Camilleri <[email protected]>
Date: Tue, 28 Apr 2026 10:17:23 +0900
Replace share sheet with link creation sheet
This commit replaces the CloudKit participant-management share sheet with a
Crossmate-owned Share Game sheet. The new sheet explains what game sharing
does, creates the CloudKit share link from an explicit Create Link button, and
then offers Copy Link and Send Link actions.
ShareController now owns the CKShare save used for link creation, so failures
are captured directly instead of being hidden inside the system share UI. Share
creation start, success, and failure events are recorded in SyncMonitor, and
Settings now includes a Share Diagnostics screen alongside the broader iCloud
Diagnostics view.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
9 files changed, 482 insertions(+), 252 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -13,6 +13,7 @@
0C39CA21BE50E49F9F06C5F2 /* PlayerRoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3292748EAE27B608C769D393 /* PlayerRoster.swift */; };
170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; };
197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; };
+ 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; };
1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; };
24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */; };
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; };
@@ -27,7 +28,6 @@
503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; };
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; };
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; };
- 6BFB5945FCCDEC64C431C2AC /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */; };
765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; };
77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; };
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; };
@@ -102,6 +102,7 @@
3292748EAE27B608C769D393 /* PlayerRoster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRoster.swift; sourceTree = "<group>"; };
33878A29B09A6154C7A63C82 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = "<group>"; };
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
+ 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = "<group>"; };
43DC132D49361C56DE79C13E /* GameMutator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutator.swift; sourceTree = "<group>"; };
457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentityTests.swift; sourceTree = "<group>"; };
46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferences.swift; sourceTree = "<group>"; };
@@ -150,7 +151,6 @@
CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; };
D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; };
- 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>"; };
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; };
@@ -278,6 +278,7 @@
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */,
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */,
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */,
+ 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */,
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */,
5ABB557BA10CBE9909056882 /* GameShareItem.swift */,
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */,
@@ -289,7 +290,6 @@
07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */,
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */,
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */,
- D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -468,6 +468,7 @@
B94919176DEC6EC31637B037 /* ClueList.swift in Sources */,
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */,
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */,
+ 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */,
978F91DBAE94BC5DA1D94705 /* DriveMonitor.swift in Sources */,
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */,
818B1F2693962832BE14578E /* GameListView.swift in Sources */,
@@ -503,7 +504,6 @@
54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */,
BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */,
AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */,
- 6BFB5945FCCDEC64C431C2AC /* SyncDiagnosticsView.swift in Sources */,
82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */,
CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */,
F46733AB3C72749A4A992667 /* SyncState+Helpers.swift in Sources */,
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -36,7 +36,8 @@ final class AppServices {
self.shareController = ShareController(
container: self.ckContainer,
persistence: persistence,
- syncEngine: syncEngine
+ syncEngine: syncEngine,
+ syncMonitor: self.syncMonitor
)
let moveBuffer = MoveBuffer(
debounceInterval: .milliseconds(1500),
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -10,18 +10,26 @@ final class ShareController {
let container: CKContainer
private let persistence: PersistenceController
private let syncEngine: SyncEngine
+ private let syncMonitor: SyncMonitor?
enum ShareError: Error {
case gameNotFound
case invalidShareRecord
case notAnOwner
case invalidGameRecord
+ case missingShareURL
}
- init(container: CKContainer, persistence: PersistenceController, syncEngine: SyncEngine) {
+ init(
+ container: CKContainer,
+ persistence: PersistenceController,
+ syncEngine: SyncEngine,
+ syncMonitor: SyncMonitor? = nil
+ ) {
self.container = container
self.persistence = persistence
self.syncEngine = syncEngine
+ self.syncMonitor = syncMonitor
}
/// Returns the `CKShare` and container for `UICloudSharingController`'s
@@ -31,6 +39,39 @@ final class ShareController {
/// `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 share = try await prepareShareRecord(for: gameID, publicPermission: .none)
+ return (share, container)
+ }
+
+ /// Creates or updates the game's CloudKit share as a public collaboration
+ /// link and returns the generated URL. This avoids the participant
+ /// management UI and lets Crossmate capture the CloudKit save error
+ /// directly when link creation fails.
+ func createShareLink(for gameID: UUID) async throws -> URL {
+ syncMonitor?.recordStart("create share link")
+ do {
+ let share = try await prepareShareRecord(for: gameID, publicPermission: .readWrite)
+ let savedRecord = try await container.privateCloudDatabase.save(share)
+ guard let savedShare = savedRecord as? CKShare else {
+ throw ShareError.invalidShareRecord
+ }
+ try persistShareName(savedShare.recordID.recordName, for: gameID)
+ guard let url = savedShare.url else {
+ throw ShareError.missingShareURL
+ }
+ syncMonitor?.note("share link created for \(gameID.uuidString): \(url.absoluteString)")
+ syncMonitor?.recordSuccess("create share link")
+ return url
+ } catch {
+ syncMonitor?.recordError("create share link", error)
+ throw error
+ }
+ }
+
+ private func prepareShareRecord(
+ for gameID: UUID,
+ publicPermission: CKShare.ParticipantPermission
+ ) async throws -> CKShare {
let ctx = persistence.viewContext
let request = NSFetchRequest<GameEntity>(entityName: "GameEntity")
request.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
@@ -44,10 +85,13 @@ final class ShareController {
if let existingName = entity.ckShareRecordName {
do {
- return try await fetchExistingShare(
+ let existing = try await fetchExistingShare(
recordName: existingName,
zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
)
+ existing.publicPermission = publicPermission
+ existing[CKShare.SystemFieldKey.title] = entity.title as CKRecordValue?
+ return existing
} catch let error as CKError where error.code == .unknownItem {
entity.ckShareRecordName = nil
try ctx.save()
@@ -73,8 +117,9 @@ final class ShareController {
try await ensureGameRecordExists(for: entity, in: zoneID)
let share = CKShare(recordZoneID: zoneID)
- share.publicPermission = .none
- return (share, container)
+ share.publicPermission = publicPermission
+ share[CKShare.SystemFieldKey.title] = entity.title as CKRecordValue?
+ return share
}
/// Records the share's CloudKit record name on the local entity so future
@@ -116,14 +161,14 @@ final class ShareController {
private func fetchExistingShare(
recordName: String,
zoneName: String
- ) async throws -> (CKShare, CKContainer) {
+ ) async throws -> CKShare {
let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID)
let record = try await container.privateCloudDatabase.record(for: recordID)
guard let share = record as? CKShare else {
throw ShareError.invalidShareRecord
}
- return (share, container)
+ return share
}
/// CloudKit requires the initial records covered by a new share to already
@@ -161,7 +206,7 @@ final class ShareController {
from: entity,
includePuzzleSource: includePuzzleSource
)
- let saved = try await saveRecord(record)
+ let saved = try await container.privateCloudDatabase.save(record)
entity.ckSystemFields = RecordSerializer.encodeSystemFields(of: saved)
entity.lastSyncedAt = Date()
if entity.ckZoneName == nil {
@@ -169,22 +214,4 @@ final class ShareController {
}
try persistence.viewContext.save()
}
-
- private func saveRecord(_ record: CKRecord) async throws -> CKRecord {
- try await withCheckedThrowingContinuation { (cont: CheckedContinuation<CKRecord, Error>) in
- let op = CKModifyRecordsOperation(recordsToSave: [record], recordIDsToDelete: nil)
- op.savePolicy = .changedKeys
- op.isAtomic = true
- op.qualityOfService = .userInitiated
- op.modifyRecordsResultBlock = { result in
- switch result {
- case .success:
- cont.resume(returning: record)
- case .failure(let error):
- cont.resume(throwing: error)
- }
- }
- self.container.privateCloudDatabase.add(op)
- }
- }
}
diff --git a/Crossmate/Views/DiagnosticsView.swift b/Crossmate/Views/DiagnosticsView.swift
@@ -0,0 +1,275 @@
+import CloudKit
+import SwiftUI
+
+struct DiagnosticsView: View {
+ @Environment(\.syncEngine) private var syncEngine
+ @Environment(SyncMonitor.self) private var syncMonitor
+
+ @State private var isSyncing = false
+
+ private static let timestampFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .none
+ formatter.timeStyle = .medium
+ return formatter
+ }()
+
+ var body: some View {
+ List {
+ Section("Status") {
+ row("Account Status", accountStatusText)
+ row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning))
+ row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")
+ row(
+ "Last Success",
+ syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None"
+ )
+ row("Last Error Phase", syncMonitor.lastErrorPhase ?? "None")
+ row("Last Error Domain", syncMonitor.lastErrorDomain ?? "None")
+ row(
+ "Last Error Code",
+ syncMonitor.lastErrorCode.map(String.init) ?? "None"
+ )
+ row("Last Error Description", syncMonitor.lastErrorDescription ?? "None")
+ }
+
+ Section("Actions") {
+ Button {
+ Task { await runFullSync() }
+ } label: {
+ HStack {
+ Text("Sync Now")
+ if isSyncing {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(isSyncing)
+
+ Button("Probe Container") {
+ Task { await probeContainer() }
+ }
+ .disabled(isSyncing)
+
+ Button("Reset Sync State", role: .destructive) {
+ Task { await resetSyncState() }
+ }
+ .disabled(isSyncing)
+ }
+
+ Section("Recent Events") {
+ if syncMonitor.entries.isEmpty {
+ Text("No events captured yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(syncMonitor.entries.reversed()) { entry in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(
+ "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())]"
+ )
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+
+ Text(entry.message)
+ .font(.caption.monospaced())
+ .textSelection(.enabled)
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ }
+ }
+ .navigationTitle("iCloud Diagnostics")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Copy") {
+ UIPasteboard.general.string = diagnosticDump
+ }
+ }
+ }
+ .task {
+ guard let syncEngine else { return }
+ let snapshot = await syncEngine.diagnosticSnapshot()
+ syncMonitor.updateSnapshot(snapshot)
+ }
+ }
+
+ // MARK: - Actions
+
+ private func resetSyncState() async {
+ guard let syncEngine else { return }
+ await syncEngine.resetSyncState()
+ syncMonitor.note("Sync state reset (zone/subscription flags and tokens cleared)")
+ let snapshot = await syncEngine.diagnosticSnapshot()
+ syncMonitor.updateSnapshot(snapshot)
+ }
+
+ private func probeContainer() async {
+ guard let syncEngine else { return }
+ syncMonitor.note("Starting container probe")
+ let results = await syncEngine.probeContainer()
+ for (name, result) in results {
+ syncMonitor.note("probe[\(name)]: \(result)")
+ }
+ syncMonitor.note("Container probe complete")
+ }
+
+ private func runFullSync() async {
+ guard !isSyncing, let syncEngine else { return }
+ isSyncing = true
+ defer { isSyncing = false }
+
+ await syncMonitor.run("manual fetch") {
+ try await syncEngine.fetchChanges()
+ }
+ await syncMonitor.run("manual push") {
+ try await syncEngine.pushChanges()
+ }
+ let snapshot = await syncEngine.diagnosticSnapshot()
+ syncMonitor.updateSnapshot(snapshot)
+ }
+
+ // MARK: - Subviews
+
+ @ViewBuilder
+ private func row(_ title: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(value)
+ .font(.body.monospaced())
+ .textSelection(.enabled)
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var accountStatusText: String {
+ guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" }
+ switch status {
+ case .available: return "Available"
+ case .noAccount: return "No Account"
+ case .restricted: return "Restricted"
+ case .couldNotDetermine: return "Could Not Determine"
+ case .temporarilyUnavailable: return "Temporarily Unavailable"
+ @unknown default: return "Unknown"
+ }
+ }
+
+ private func boolText(_ value: Bool?) -> String {
+ guard let value else { return "Unknown" }
+ return value ? "Yes" : "No"
+ }
+
+ private var diagnosticDump: String {
+ var lines: [String] = []
+ lines.append("Account Status: \(accountStatusText)")
+ lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
+ lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
+ lines.append(
+ "Last Success: \(syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None")"
+ )
+ lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
+ lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
+ lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
+ lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
+ lines.append("")
+ lines.append("Recent Events:")
+ for entry in syncMonitor.entries {
+ lines.append(
+ "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
+ )
+ }
+ return lines.joined(separator: "\n")
+ }
+}
+
+struct ShareDiagnosticsView: View {
+ @Environment(SyncMonitor.self) private var syncMonitor
+
+ private static let timestampFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .none
+ formatter.timeStyle = .medium
+ return formatter
+ }()
+
+ private var shareEntries: [SyncDiagnosticEntry] {
+ syncMonitor.entries.filter {
+ $0.message.localizedCaseInsensitiveContains("share")
+ }
+ }
+
+ var body: some View {
+ List {
+ Section("Last Share Error") {
+ row("Phase", syncMonitor.lastErrorPhase ?? "None")
+ row("Domain", syncMonitor.lastErrorDomain ?? "None")
+ row("Code", syncMonitor.lastErrorCode.map(String.init) ?? "None")
+ row("Description", syncMonitor.lastErrorDescription ?? "None")
+ }
+
+ Section("Share Events") {
+ if shareEntries.isEmpty {
+ Text("No share events captured yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(shareEntries.reversed()) { entry in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(
+ "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())]"
+ )
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+
+ Text(entry.message)
+ .font(.caption.monospaced())
+ .textSelection(.enabled)
+ }
+ .padding(.vertical, 2)
+ }
+ }
+ }
+ }
+ .navigationTitle("Share Diagnostics")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Copy") {
+ UIPasteboard.general.string = diagnosticDump
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func row(_ title: String, _ value: String) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Text(value)
+ .font(.body.monospaced())
+ .textSelection(.enabled)
+ }
+ .padding(.vertical, 2)
+ }
+
+ private var diagnosticDump: String {
+ var lines: [String] = []
+ lines.append("Last Share Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
+ lines.append("Last Share Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
+ lines.append("Last Share Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
+ lines.append("Last Share Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
+ lines.append("")
+ lines.append("Share Events:")
+ for entry in shareEntries {
+ lines.append(
+ "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
+ )
+ }
+ return lines.joined(separator: "\n")
+ }
+}
diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift
@@ -176,6 +176,7 @@ private struct GameRowView: View {
var onLeave: () -> Void = {}
var onResign: () -> Void = {}
var onDelete: () -> Void = {}
+ @State private var isShowingShareSheet = false
var body: some View {
HStack(spacing: 12) {
@@ -206,15 +207,9 @@ 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)
- ) {
+ Button {
+ isShowingShareSheet = true
+ } label: {
Image(systemName: "square.and.arrow.up")
.font(.body)
.frame(width: 32, height: 32)
@@ -242,5 +237,12 @@ private struct GameRowView: View {
.compositingGroup()
}
.padding(.vertical, 4)
+ .sheet(isPresented: $isShowingShareSheet) {
+ GameShareSheet(
+ gameID: game.id,
+ title: game.title,
+ shareController: shareController
+ )
+ }
}
}
diff --git a/Crossmate/Views/GameShareItem.swift b/Crossmate/Views/GameShareItem.swift
@@ -1,25 +1,130 @@
-import CloudKit
-import CoreTransferable
import Foundation
+import SwiftUI
-/// `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 {
+struct GameShareSheet: View {
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
+ @Environment(\.dismiss) private var dismiss
+ @State private var shareURL: URL?
+ @State private var errorMessage: String?
+ @State private var isCreating = false
+ @State private var didCopy = false
+
+ var body: some View {
+ NavigationStack {
+ List {
+ VStack(spacing: 18) {
+ Image(systemName: "flag.pattern.checkered.2.crossed")
+ .font(.system(size: 58, weight: .semibold))
+ .symbolRenderingMode(.hierarchical)
+ .foregroundStyle(Color.accentColor)
+ .frame(width: 88, height: 88)
+ .accessibilityHidden(true)
+
+ VStack(spacing: 12) {
+ Text("Share via iCloud")
+ .font(.title3.weight(.semibold))
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Text("Any iCloud user with the link will be able to join your game and collaborate.")
+ .font(.body)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Text("Crossmate syncs games using iCloud. Please be aware that your player name is shared with other players. Players can work on the puzzle simultaneously or at different times.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 8)
+ .listRowInsets(EdgeInsets())
+ .listRowBackground(Color.clear)
+
+ Section {
+ if let shareURL {
+ LabeledContent("Link") {
+ Text(shareURL.absoluteString)
+ .font(.caption.monospaced())
+ .textSelection(.enabled)
+ .multilineTextAlignment(.trailing)
+ }
+
+ Button {
+ UIPasteboard.general.string = shareURL.absoluteString
+ didCopy = true
+ } label: {
+ Label(didCopy ? "Copied" : "Copy Link", systemImage: didCopy ? "checkmark" : "doc.on.doc")
+ }
+
+ ShareLink(item: shareURL) {
+ Label("Send Link", systemImage: "square.and.arrow.up")
+ }
+ } else {
+ Button {
+ Task { await createLink() }
+ } label: {
+ HStack {
+ Label("Create Link", systemImage: "link")
+ if isCreating {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(isCreating)
+ }
+ }
+
+ if let errorMessage {
+ Section("Error") {
+ Text(errorMessage)
+ .font(.caption.monospaced())
+ .foregroundStyle(.red)
+ .textSelection(.enabled)
+ Button {
+ UIPasteboard.general.string = errorMessage
+ } label: {
+ Label("Copy Error", systemImage: "doc.on.doc")
+ }
+ }
+ }
+ }
+ .navigationTitle("Share Game")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
}
}
}
+
+ private func createLink() async {
+ guard !isCreating else { return }
+ isCreating = true
+ didCopy = false
+ errorMessage = nil
+ defer { isCreating = false }
+
+ do {
+ shareURL = try await shareController.createShareLink(for: gameID)
+ } catch {
+ errorMessage = describe(error)
+ }
+ }
+
+ private func describe(_ error: Error) -> String {
+ let nsError = error as NSError
+ let userInfo = nsError.userInfo
+ .map { "\($0.key)=\($0.value)" }
+ .joined(separator: " | ")
+ return "domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)\n\(userInfo)"
+ }
}
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -14,6 +14,7 @@ struct PuzzleView: View {
@State private var isConfirmingLeave = false
@State private var leaveError: String?
@State private var isRevokedBannerDismissed = false
+ @State private var isShowingShareSheet = false
private func swatchImage(for color: PlayerColor) -> Image {
let tint = UIColor(color.tint)
@@ -183,16 +184,10 @@ struct PuzzleView: View {
}
.disabled(!(session.mutator.isShared && !session.mutator.isOwned) || shareController == nil)
- if let shareController {
- ShareLink(
- item: GameShareItem(
- gameID: session.mutator.gameID,
- title: session.puzzle.title,
- container: shareController.container,
- shareController: shareController
- ),
- preview: SharePreview(session.puzzle.title)
- ) {
+ if shareController != nil {
+ Button {
+ isShowingShareSheet = true
+ } label: {
Text("Share Game")
}
.disabled(!session.mutator.isOwned)
@@ -264,6 +259,15 @@ struct PuzzleView: View {
} message: {
Text("Enter the name other players will see.")
}
+ .sheet(isPresented: $isShowingShareSheet) {
+ if let shareController {
+ GameShareSheet(
+ gameID: session.mutator.gameID,
+ title: session.puzzle.title,
+ shareController: shareController
+ )
+ }
+ }
}
private func leaveSharedGame() async {
diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift
@@ -23,7 +23,10 @@ struct SettingsView: View {
Section("Debugging") {
NavigationLink("iCloud Diagnostics") {
- SyncDiagnosticsView()
+ DiagnosticsView()
+ }
+ NavigationLink("Share Diagnostics") {
+ ShareDiagnosticsView()
}
Button("Reset Database", role: .destructive) {
diff --git a/Crossmate/Views/SyncDiagnosticsView.swift b/Crossmate/Views/SyncDiagnosticsView.swift
@@ -1,187 +0,0 @@
-import CloudKit
-import SwiftUI
-
-struct SyncDiagnosticsView: View {
- @Environment(\.syncEngine) private var syncEngine
- @Environment(SyncMonitor.self) private var syncMonitor
-
- @State private var isSyncing = false
-
- private static let timestampFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateStyle = .none
- formatter.timeStyle = .medium
- return formatter
- }()
-
- var body: some View {
- List {
- Section("Status") {
- row("Account Status", accountStatusText)
- row("Engine Running", boolText(syncMonitor.snapshot?.engineRunning))
- row("Pending Changes", syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")
- row(
- "Last Success",
- syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None"
- )
- row("Last Error Phase", syncMonitor.lastErrorPhase ?? "None")
- row("Last Error Domain", syncMonitor.lastErrorDomain ?? "None")
- row(
- "Last Error Code",
- syncMonitor.lastErrorCode.map(String.init) ?? "None"
- )
- row("Last Error Description", syncMonitor.lastErrorDescription ?? "None")
- }
-
- Section("Actions") {
- Button {
- Task { await runFullSync() }
- } label: {
- HStack {
- Text("Sync Now")
- if isSyncing {
- Spacer()
- ProgressView()
- }
- }
- }
- .disabled(isSyncing)
-
- Button("Probe Container") {
- Task { await probeContainer() }
- }
- .disabled(isSyncing)
-
- Button("Reset Sync State", role: .destructive) {
- Task { await resetSyncState() }
- }
- .disabled(isSyncing)
- }
-
- Section("Recent Events") {
- if syncMonitor.entries.isEmpty {
- Text("No events captured yet.")
- .foregroundStyle(.secondary)
- } else {
- ForEach(syncMonitor.entries.reversed()) { entry in
- VStack(alignment: .leading, spacing: 4) {
- Text(
- "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())]"
- )
- .font(.caption.monospaced())
- .foregroundStyle(.secondary)
-
- Text(entry.message)
- .font(.caption.monospaced())
- .textSelection(.enabled)
- }
- .padding(.vertical, 2)
- }
- }
- }
- }
- .navigationTitle("iCloud Diagnostics")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Copy") {
- UIPasteboard.general.string = diagnosticDump
- }
- }
- }
- .task {
- guard let syncEngine else { return }
- let snapshot = await syncEngine.diagnosticSnapshot()
- syncMonitor.updateSnapshot(snapshot)
- }
- }
-
- // MARK: - Actions
-
- private func resetSyncState() async {
- guard let syncEngine else { return }
- await syncEngine.resetSyncState()
- syncMonitor.note("Sync state reset (zone/subscription flags and tokens cleared)")
- let snapshot = await syncEngine.diagnosticSnapshot()
- syncMonitor.updateSnapshot(snapshot)
- }
-
- private func probeContainer() async {
- guard let syncEngine else { return }
- syncMonitor.note("Starting container probe")
- let results = await syncEngine.probeContainer()
- for (name, result) in results {
- syncMonitor.note("probe[\(name)]: \(result)")
- }
- syncMonitor.note("Container probe complete")
- }
-
- private func runFullSync() async {
- guard !isSyncing, let syncEngine else { return }
- isSyncing = true
- defer { isSyncing = false }
-
- await syncMonitor.run("manual fetch") {
- try await syncEngine.fetchChanges()
- }
- await syncMonitor.run("manual push") {
- try await syncEngine.pushChanges()
- }
- let snapshot = await syncEngine.diagnosticSnapshot()
- syncMonitor.updateSnapshot(snapshot)
- }
-
- // MARK: - Subviews
-
- @ViewBuilder
- private func row(_ title: String, _ value: String) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.caption)
- .foregroundStyle(.secondary)
- Text(value)
- .font(.body.monospaced())
- .textSelection(.enabled)
- }
- .padding(.vertical, 2)
- }
-
- private var accountStatusText: String {
- guard let status = syncMonitor.snapshot?.accountStatus else { return "Unknown" }
- switch status {
- case .available: return "Available"
- case .noAccount: return "No Account"
- case .restricted: return "Restricted"
- case .couldNotDetermine: return "Could Not Determine"
- case .temporarilyUnavailable: return "Temporarily Unavailable"
- @unknown default: return "Unknown"
- }
- }
-
- private func boolText(_ value: Bool?) -> String {
- guard let value else { return "Unknown" }
- return value ? "Yes" : "No"
- }
-
- private var diagnosticDump: String {
- var lines: [String] = []
- lines.append("Account Status: \(accountStatusText)")
- lines.append("Engine Running: \(boolText(syncMonitor.snapshot?.engineRunning))")
- lines.append("Pending Changes: \(syncMonitor.snapshot.map { String($0.pendingChangesCount) } ?? "Unknown")")
- lines.append(
- "Last Success: \(syncMonitor.lastSuccessAt.map(Self.timestampFormatter.string(from:)) ?? "None")"
- )
- lines.append("Last Error Phase: \(syncMonitor.lastErrorPhase ?? "None")")
- lines.append("Last Error Domain: \(syncMonitor.lastErrorDomain ?? "None")")
- lines.append("Last Error Code: \(syncMonitor.lastErrorCode.map(String.init) ?? "None")")
- lines.append("Last Error Description: \(syncMonitor.lastErrorDescription ?? "None")")
- lines.append("")
- lines.append("Recent Events:")
- for entry in syncMonitor.entries {
- lines.append(
- "\(Self.timestampFormatter.string(from: entry.timestamp)) [\(entry.level.uppercased())] \(entry.message)"
- )
- }
- return lines.joined(separator: "\n")
- }
-}