commit 5e69e71ac54b4f027c6aabd133f0b7036d51816b
parent 34d5d0683648994eabd4a4fe65c8992f0603ac24
Author: Michael Camilleri <[email protected]>
Date: Sat, 25 Apr 2026 04:12:11 +0900
Add CKShare sharing via per-game zones and dual sync engines
This commit implements Phase 4 of the sync redesign: two iCloud users can now
collaborate on one puzzle via CKShare.
Each Game record now lives in its own CloudKit zone (zone name "game-<UUID>"),
so a zone-scoped CKShare covers the whole game — Move and Snapshot records come
along automatically via their parent reference. SyncEngine drives two
CKSyncEngine instances in parallel: one against privateCloudDatabase for owned
games, one against sharedCloudDatabase for joined games. Outbound enqueues
route by GameEntity.databaseScope; inbound events route by which engine fired
them. Each engine persists its own state in SyncStateEntity.
Move attribution now uses the current iCloud userRecordID, cached in a new
AuthorIdentity helper and stamped on moves at enqueue time. Participants whose
zone disappears from the shared database are marked isAccessRevoked;
GameMutator.emitMove becomes a no-op and PuzzleView shows a dismissible banner
explaining the state.
Sharing is triggered from the library row menu or the puzzle toolbar via
UICloudSharingController, backed by a new ShareController that creates and
persists the CKShare. Participants leave a shared game by deleting the zone on
sharedCloudDatabase.
resetAllData now also tears down private zones and leaves shared zones so the
diagnostics reset button produces a clean slate in CloudKit as well as locally.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
20 files changed, 1088 insertions(+), 176 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -7,7 +7,9 @@
objects = {
/* Begin PBXBuildFile section */
+ 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */; };
0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */; };
+ 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.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 */; };
@@ -36,6 +38,7 @@
97D77230A98330DCB757FA81 /* sample.xd in Resources */ = {isa = PBXBuildFile; fileRef = 5C63A148D98E2D37EABF2CF5 /* sample.xd */; };
98F8FBF324ED00D53FEBB1DB /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F2BB469EFE84CF3733398 /* Game.swift */; };
9CB8808193A4A106D721D767 /* XDFileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAC61E2582D94B1E6EC67136 /* XDFileType.swift */; };
+ A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */; };
AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */; };
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; };
AA992F67F509EC8EFDDFC7CB /* morning.xd in Resources */ = {isa = PBXBuildFile; fileRef = 0B73A791FD061430AE286E11 /* morning.xd */; };
@@ -43,6 +46,8 @@
B42454D72FAA219D60DEA334 /* garden.xd in Resources */ = {isa = PBXBuildFile; fileRef = 50992CDA4082429EBB17F65C /* garden.xd */; };
B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6F111BE8750697C4BC7A17 /* NYTToXDConverter.swift */; };
B94919176DEC6EC31637B037 /* ClueList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */; };
+ BCB9A4D5E06EE5006186465D /* ShareController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C74683332956B0D1CA37589 /* ShareController.swift */; };
+ BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */; };
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F93AC31640C40FCC039570A3 /* CrossmateModel.xcdatamodeld */; };
C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */; };
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */; };
@@ -55,6 +60,7 @@
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 */; };
@@ -81,9 +87,11 @@
1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; };
20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; };
26397B9DBC57DCF7B58899D4 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; };
+ 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerGameZoneTests.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>"; };
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>"; };
465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; };
@@ -91,7 +99,9 @@
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>"; };
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>"; };
+ 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; };
70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; };
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; };
74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
@@ -110,6 +120,7 @@
AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleView.swift; sourceTree = "<group>"; };
B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTPuzzleFetcher.swift; sourceTree = "<group>"; };
B135C285570F91181595B405 /* CellMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellMark.swift; sourceTree = "<group>"; };
+ B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorIdentity.swift; sourceTree = "<group>"; };
B689A7138429641E61E9E558 /* Crossmate.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Crossmate.app; sourceTree = BUILT_PRODUCTS_DIR; };
B9031A1574C21866940F6A2C /* XD.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XD.swift; sourceTree = "<group>"; };
BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBufferTests.swift; sourceTree = "<group>"; };
@@ -124,6 +135,7 @@
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>"; };
@@ -146,9 +158,11 @@
074C2962E79CAE6C0EA6431A /* Sync */ = {
isa = PBXGroup;
children = (
+ B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */,
52B8E26067849A63758DDEA4 /* MoveBuffer.swift */,
F7422F19AA1F1692A98E3602 /* MoveLog.swift */,
0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */,
+ 5C74683332956B0D1CA37589 /* ShareController.swift */,
73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */,
AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */,
9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */,
@@ -173,6 +187,7 @@
543481AA9FA32BF14076EB1C /* MoveLogTests.swift */,
C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */,
7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */,
+ ABB371EF2574E95782CB05FD /* Sync */,
);
name = Unit;
path = Tests/Unit;
@@ -238,6 +253,7 @@
9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */,
C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */,
F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */,
+ E62B3141EBEDC2D040DDBC0B /* CloudSharingView.swift */,
E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */,
38DDAD9D6470A894C3FD6F90 /* GameListView.swift */,
D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */,
@@ -254,6 +270,16 @@
path = Views;
sourceTree = "<group>";
};
+ ABB371EF2574E95782CB05FD /* Sync */ = {
+ isa = PBXGroup;
+ children = (
+ 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */,
+ 283C5C97180C805B6C5BF622 /* PerGameZoneTests.swift */,
+ 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */,
+ );
+ path = Sync;
+ sourceTree = "<group>";
+ };
C5342A31D253372339517EEE = {
isa = PBXGroup;
children = (
@@ -385,11 +411,14 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */,
2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */,
3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */,
24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */,
AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */,
+ 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */,
ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */,
+ BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */,
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -399,10 +428,12 @@
buildActionMask = 2147483647;
files = (
78802AFDF6273231781CC0DC /* AppServices.swift in Sources */,
+ 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */,
AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */,
C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */,
6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */,
CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */,
+ EE7DE19BE6F788B8D3D60DF2 /* CloudSharingView.swift in Sources */,
B94919176DEC6EC31637B037 /* ClueList.swift in Sources */,
DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */,
C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */,
@@ -434,6 +465,7 @@
2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */,
CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */,
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 */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -1,3 +1,4 @@
+import CloudKit
import SwiftUI
@main
@@ -31,6 +32,7 @@ struct CrossmateApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable {
var onRemoteNotification: (() async -> Void)?
+ var onAcceptShare: ((CKShare.Metadata) async -> Void)?
func application(
_ application: UIApplication,
@@ -47,6 +49,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable {
await onRemoteNotification?()
return .newData
}
+
+ func application(
+ _ application: UIApplication,
+ userDidAcceptCloudKitShareWith metadata: CKShare.Metadata
+ ) {
+ Task { await onAcceptShare?(metadata) }
+ }
}
// MARK: - Root View
@@ -63,12 +72,14 @@ struct RootView: View {
NavigationStack(path: $navigationPath) {
GameListView(
store: services.store,
+ shareController: services.shareController,
navigationPath: $navigationPath
)
.navigationDestination(for: UUID.self) { gameID in
PuzzleDisplayView(
gameID: gameID,
- store: services.store
+ store: services.store,
+ shareController: services.shareController
)
}
}
@@ -100,6 +111,7 @@ struct RootView: View {
private struct PuzzleDisplayView: View {
let gameID: UUID
let store: GameStore
+ let shareController: ShareController
@State private var session: PlayerSession?
@State private var loadError: String?
@@ -107,7 +119,11 @@ private struct PuzzleDisplayView: View {
var body: some View {
Group {
if let session {
- PuzzleView(session: session, onComplete: { store.markCompleted(id: gameID) })
+ PuzzleView(
+ session: session,
+ shareController: shareController,
+ onComplete: { store.markCompleted(id: gameID) }
+ )
} else if let loadError {
ContentUnavailableView(
"Couldn't load puzzle",
diff --git a/Crossmate/Info.plist b/Crossmate/Info.plist
@@ -33,6 +33,8 @@
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
+ <key>CKSharingSupported</key>
+ <true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -2,11 +2,15 @@
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="24F74" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="NO" userDefinedModelVersionIdentifier="">
<entity name="GameEntity" representedClassName="GameEntity" syncable="YES" codeGenerationType="class">
<attribute name="ckRecordName" optional="YES" attributeType="String"/>
+ <attribute name="ckShareRecordName" optional="YES" attributeType="String"/>
<attribute name="ckSystemFields" optional="YES" attributeType="Binary"/>
+ <attribute name="ckZoneName" optional="YES" attributeType="String"/>
+ <attribute name="ckZoneOwnerName" optional="YES" attributeType="String"/>
<attribute name="completedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="databaseScope" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
+ <attribute name="isAccessRevoked" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="lamportHighWater" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="puzzleSource" attributeType="String"/>
@@ -60,7 +64,8 @@
<relationship name="game" maxCount="1" deletionRule="Nullify" destinationEntity="GameEntity" inverseName="cells" inverseEntity="GameEntity"/>
</entity>
<entity name="SyncStateEntity" representedClassName="SyncStateEntity" syncable="YES" codeGenerationType="class">
- <attribute name="ckEngineState" optional="YES" attributeType="Binary"/>
+ <attribute name="ckPrivateEngineState" optional="YES" attributeType="Binary"/>
+ <attribute name="ckSharedEngineState" optional="YES" attributeType="Binary"/>
<attribute name="id" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastSyncedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -11,15 +11,40 @@ import Foundation
///
/// All methods are `@MainActor` because `Game` is `@MainActor`.
@MainActor
+@Observable
final class GameMutator {
private let game: Game
- private let gameID: UUID
+ let gameID: UUID
private let moveBuffer: MoveBuffer?
+ private let authorIDProvider: (@Sendable () -> String?)?
- init(game: Game, gameID: UUID, moveBuffer: MoveBuffer?) {
+ /// `true` when the current user owns the CloudKit zone for this game.
+ let isOwned: Bool
+ /// `true` when the game is shared — either the owner has an active share
+ /// or the current user joined via one.
+ let isShared: Bool
+
+ /// Set to `true` when the owner has revoked the current user's access to
+ /// a shared game. `emitMove` becomes a no-op and `PuzzleView` shows a
+ /// read-only banner.
+ var isAccessRevoked: Bool
+
+ init(
+ game: Game,
+ gameID: UUID,
+ moveBuffer: MoveBuffer?,
+ authorIDProvider: (@Sendable () -> String?)? = nil,
+ isOwned: Bool = true,
+ isShared: Bool = false,
+ isAccessRevoked: Bool = false
+ ) {
self.game = game
self.gameID = gameID
self.moveBuffer = moveBuffer
+ self.authorIDProvider = authorIDProvider
+ self.isOwned = isOwned
+ self.isShared = isShared
+ self.isAccessRevoked = isAccessRevoked
}
// MARK: - Single-cell mutations
@@ -66,11 +91,12 @@ final class GameMutator {
// MARK: - Helpers
private func emitMove(atRow row: Int, atCol col: Int) {
- guard let moveBuffer else { return }
+ guard let moveBuffer, !isAccessRevoked else { return }
let square = game.squares[row][col]
let (markKind, checkedWrong) = encodeMark(square.mark)
let id = gameID
let letter = square.entry
+ let authorID = authorIDProvider?()
Task {
await moveBuffer.enqueue(
gameID: id,
@@ -78,7 +104,7 @@ final class GameMutator {
letter: letter,
markKind: markKind,
checkedWrong: checkedWrong,
- authorID: nil
+ authorID: authorID
)
}
}
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -22,6 +22,12 @@ struct GameSummary: Identifiable, Equatable {
let gridWidth: Int
let gridHeight: Int
let thumbnailCells: [GameThumbnailCell]
+ /// `true` when the current user owns this game (`databaseScope == 0`).
+ let isOwned: Bool
+ /// `true` when this game has an active share (owner) or is joined via
+ /// a share (participant, `databaseScope == 1`).
+ let isShared: Bool
+ let isAccessRevoked: Bool
init?(entity: GameEntity) {
guard let id = entity.id,
@@ -60,6 +66,9 @@ struct GameSummary: Identifiable, Equatable {
self.gridWidth = puzzle.width
self.gridHeight = puzzle.height
self.thumbnailCells = thumbCells
+ self.isOwned = entity.databaseScope == 0
+ self.isShared = entity.ckShareRecordName != nil || entity.databaseScope == 1
+ self.isAccessRevoked = entity.isAccessRevoked
}
}
@@ -83,6 +92,11 @@ final class GameStore {
@ObservationIgnored
var moveBuffer: MoveBuffer?
+ /// Provides the current iCloud author ID at move-emit time. Set by
+ /// `AppServices` after `AuthorIdentity` is initialised.
+ @ObservationIgnored
+ var authorIDProvider: (@Sendable () -> String?)?
+
/// Called when a new game's `ckRecordName` is ready to push. Wired to
/// `SyncEngine.enqueueGame` by `AppServices`.
@ObservationIgnored
@@ -294,6 +308,8 @@ final class GameStore {
entity.createdAt = now
entity.updatedAt = now
entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.ckZoneName = "game-\(gameID.uuidString)"
+ entity.databaseScope = 0
try context.save()
onGameCreated?("game-\(gameID.uuidString)")
@@ -444,6 +460,8 @@ final class GameStore {
entity.createdAt = now
entity.updatedAt = now
entity.ckRecordName = "game-\(gameID.uuidString)"
+ entity.ckZoneName = "game-\(gameID.uuidString)"
+ entity.databaseScope = 0
try context.save()
onGameCreated?("game-\(gameID.uuidString)")
@@ -533,11 +551,26 @@ final class GameStore {
try? context.save()
}
+ /// Marks the active game read-only when the sync engine sees its shared
+ /// zone disappear from the shared database (owner revoked access).
+ func markAccessRevoked(gameID: UUID) {
+ guard currentEntity?.id == gameID else { return }
+ currentMutator?.isAccessRevoked = true
+ }
+
private func makeMutator(game: Game, entity: GameEntity) -> GameMutator {
guard let gameID = entity.id else {
fatalError("GameEntity missing id — data model invariant violated")
}
- return GameMutator(game: game, gameID: gameID, moveBuffer: moveBuffer)
+ return GameMutator(
+ game: game,
+ gameID: gameID,
+ moveBuffer: moveBuffer,
+ authorIDProvider: authorIDProvider,
+ isOwned: entity.databaseScope == 0,
+ isShared: entity.ckShareRecordName != nil || entity.databaseScope == 1,
+ isAccessRevoked: entity.isAccessRevoked
+ )
}
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -11,22 +11,30 @@ final class AppServices {
let driveMonitor: DriveMonitor
let nytFetcher: NYTPuzzleFetcher
let moveBuffer: MoveBuffer
+ let identity: AuthorIdentity
+ let shareController: ShareController
+ private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2")
private var started = false
init() {
self.persistence = PersistenceController()
let store = GameStore(persistence: persistence)
self.store = store
- let syncEngine = SyncEngine(
- container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v2"),
- persistence: persistence
- )
+ let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2")
+ let syncEngine = SyncEngine(container: ckContainer, persistence: persistence)
self.syncEngine = syncEngine
self.syncMonitor = SyncMonitor()
self.nytAuth = NYTAuthService()
self.driveMonitor = DriveMonitor()
self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() }
+ let identity = AuthorIdentity()
+ self.identity = identity
+ self.shareController = ShareController(
+ container: ckContainer,
+ persistence: persistence,
+ syncEngine: syncEngine
+ )
let moveBuffer = MoveBuffer(
debounceInterval: .milliseconds(1500),
persistence: persistence,
@@ -44,6 +52,7 @@ final class AppServices {
)
self.moveBuffer = moveBuffer
store.moveBuffer = moveBuffer
+ store.authorIDProvider = { identity.currentID }
store.onGameCreated = { [syncEngine] ckRecordName in
Task { await syncEngine.enqueueGame(ckRecordName: ckRecordName) }
}
@@ -65,6 +74,9 @@ final class AppServices {
appDelegate.onRemoteNotification = {
await self.handleRemoteNotification()
}
+ appDelegate.onAcceptShare = { metadata in
+ await self.acceptShare(metadata: metadata)
+ }
await syncEngine.setTracer { [syncMonitor] message in
syncMonitor.note(message)
@@ -76,6 +88,18 @@ final class AppServices {
store.refreshCurrentGame()
}
+ await syncEngine.setOnAccountChange { [weak self] in
+ guard let self else { return }
+ await self.identity.refresh(using: self.ckContainer)
+ }
+
+ await syncEngine.setOnGameAccessRevoked { [store] gameID in
+ store.markAccessRevoked(gameID: gameID)
+ }
+
+ // Fetch identity before starting engines so first moves get an authorID.
+ await identity.refresh(using: ckContainer)
+
await syncEngine.start()
await Self.run("initial fetch", monitor: syncMonitor) {
@@ -98,17 +122,45 @@ final class AppServices {
await refreshSnapshot()
}
- /// Flushes any buffered keystrokes so pending moves reach CloudKit before
- /// the app suspends.
func syncOnBackground() async {
await moveBuffer.flush()
}
- /// Wipes all local games and clears the CloudKit sync-engine state.
- /// The next launch will start with an empty database and fetch everything
- /// fresh from the server.
+ /// Accepts a CloudKit share invitation. Runs `CKAcceptSharesOperation`,
+ /// then kicks the shared engine to fetch the new zone's records.
+ func acceptShare(metadata: CKShare.Metadata) async {
+ guard metadata.containerIdentifier == ckContainer.containerIdentifier else {
+ syncMonitor.note(
+ "acceptShare: container mismatch — metadata=\(metadata.containerIdentifier) " +
+ "expected=\(ckContainer.containerIdentifier ?? "nil")"
+ )
+ return
+ }
+ do {
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
+ let op = CKAcceptSharesOperation(shareMetadatas: [metadata])
+ op.acceptSharesResultBlock = { result in cont.resume(with: result) }
+ ckContainer.add(op)
+ }
+ syncMonitor.note("Share accepted — fetching shared zone")
+ await Self.run("share-accept fetch", monitor: syncMonitor) {
+ try await syncEngine.fetchChanges()
+ }
+ } catch {
+ syncMonitor.recordError("acceptShare", error)
+ }
+ }
+
+ /// Wipes all local games, clears both engine states, and removes all
+ /// CloudKit zones. Falls back gracefully if the network is unavailable.
func resetAllData() async {
await syncEngine.resetSyncState()
+
+ // Best-effort CloudKit cleanup — don't block reset if offline.
+ async let privateCleanup: Void = deleteAllPrivateZones()
+ async let sharedCleanup: Void = leaveAllSharedZones()
+ _ = await (privateCleanup, sharedCleanup)
+
store.resetAllData()
syncMonitor.note("Database reset — all games and sync state cleared")
}
@@ -151,6 +203,35 @@ final class AppServices {
syncMonitor.updateSnapshot(snapshot)
}
+ private func deleteAllPrivateZones() async {
+ do {
+ let zones = try await ckContainer.privateCloudDatabase.allRecordZones()
+ guard !zones.isEmpty else { return }
+ let ids = zones.map(\.zoneID)
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
+ let op = CKModifyRecordZonesOperation(
+ recordZonesToSave: nil,
+ recordZoneIDsToDelete: ids
+ )
+ op.modifyRecordZonesResultBlock = { result in cont.resume(with: result) }
+ ckContainer.privateCloudDatabase.add(op)
+ }
+ } catch {
+ syncMonitor.note("reset: private zone cleanup failed — \(error)")
+ }
+ }
+
+ private func leaveAllSharedZones() async {
+ do {
+ let zones = try await ckContainer.sharedCloudDatabase.allRecordZones()
+ for zone in zones {
+ try await ckContainer.sharedCloudDatabase.deleteRecordZone(withID: zone.zoneID)
+ }
+ } catch {
+ syncMonitor.note("reset: shared zone cleanup failed — \(error)")
+ }
+ }
+
static func run(
_ phase: String,
monitor: SyncMonitor,
diff --git a/Crossmate/Sync/AuthorIdentity.swift b/Crossmate/Sync/AuthorIdentity.swift
@@ -0,0 +1,24 @@
+import CloudKit
+import Foundation
+import os
+
+/// Thread-safe cache for the current iCloud user's record name. Populated
+/// asynchronously at app start and refreshed on account-change events from
+/// `CKSyncEngine`. The synchronous `currentID` getter lets `GameMutator`
+/// stamp moves without an `await` at the call site.
+final class AuthorIdentity: Sendable {
+ private let storage: OSAllocatedUnfairLock<String?>
+
+ init() {
+ storage = OSAllocatedUnfairLock(initialState: nil)
+ }
+
+ var currentID: String? {
+ storage.withLock { $0 }
+ }
+
+ func refresh(using container: CKContainer) async {
+ guard let recordID = try? await container.userRecordID() else { return }
+ storage.withLock { $0 = recordID.recordName }
+ }
+}
diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift
@@ -5,7 +5,6 @@ import Foundation
/// Pure-function helpers for converting between the app's Core Data / in-memory
/// models and CloudKit `CKRecord` objects. Stateless — all context is passed in.
enum RecordSerializer {
- static let zoneName = "CrossmateZone"
// MARK: - Device identity
@@ -47,12 +46,14 @@ enum RecordSerializer {
// MARK: - Zone
- static func zone() -> CKRecordZone {
- CKRecordZone(zoneName: zoneName)
- }
-
- static func zoneID() -> CKRecordZone.ID {
- CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
+ /// Zone ID for a per-game zone. `ownerName` defaults to the current user
+ /// placeholder; pass an explicit value for shared games where the zone is
+ /// owned by another iCloud account.
+ static func zoneID(
+ for gameID: UUID,
+ ownerName: String = CKCurrentUserDefaultName
+ ) -> CKRecordZone.ID {
+ CKRecordZone.ID(zoneName: "game-\(gameID.uuidString)", ownerName: ownerName)
}
// MARK: - Move / Snapshot record building
@@ -200,7 +201,8 @@ enum RecordSerializer {
static func applyGameRecord(
_ record: CKRecord,
- to context: NSManagedObjectContext
+ to context: NSManagedObjectContext,
+ databaseScope: Int16 = 0
) -> GameEntity {
let recordName = record.recordID.recordName
let entity = fetchOrCreate(
@@ -229,6 +231,12 @@ enum RecordSerializer {
entity.ckSystemFields = encodeSystemFields(of: record)
entity.title = record["title"] as? String ?? entity.title
entity.completedAt = record["completedAt"] as? Date
+ entity.databaseScope = databaseScope
+
+ // Persist the zone identity so outbound moves use the right zone ID.
+ entity.ckZoneName = record.recordID.zoneID.zoneName
+ let ownerName = record.recordID.zoneID.ownerName
+ entity.ckZoneOwnerName = ownerName == CKCurrentUserDefaultName ? nil : ownerName
if let asset = record["puzzleSource"] as? CKAsset,
let fileURL = asset.fileURL,
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -0,0 +1,105 @@
+import CloudKit
+import CoreData
+import Foundation
+
+/// Manages the lifecycle of `CKShare` objects for per-game zones. Responsible
+/// for creating zone-scoped shares and saving them to CloudKit, refreshing
+/// existing shares on re-present, and letting participants leave a shared game.
+@MainActor
+final class ShareController {
+ private let container: CKContainer
+ private let persistence: PersistenceController
+ private let syncEngine: SyncEngine
+
+ enum ShareError: Error {
+ case gameNotFound
+ case invalidShareRecord
+ case notAnOwner
+ }
+
+ init(container: CKContainer, persistence: PersistenceController, syncEngine: SyncEngine) {
+ self.container = container
+ self.persistence = persistence
+ 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.
+ func prepareShare(for gameID: UUID) async throws -> (CKShare, CKContainer) {
+ 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 {
+ throw ShareError.gameNotFound
+ }
+ guard entity.databaseScope == 0 else {
+ throw ShareError.notAnOwner
+ }
+
+ // Return an existing share so the controller can update participants.
+ if let existingName = entity.ckShareRecordName {
+ return try await fetchExistingShare(
+ recordName: existingName,
+ zoneName: entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ )
+ }
+
+ // Ensure the zone exists in CloudKit before creating the share.
+ try await syncEngine.pushChanges()
+
+ let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
+ let share = CKShare(recordZoneID: zoneID)
+ share.publicPermission = .none
+
+ 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
+ try ctx.save()
+
+ return (share, container)
+ }
+
+ /// Removes the current user's participation from a shared game and deletes
+ /// the local entity. No-ops if the game is not a shared (participant) game.
+ func leaveShare(gameID: UUID) async 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,
+ entity.databaseScope == 1 else { return }
+
+ let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
+ let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
+
+ // Deleting the zone from the shared database removes our participation.
+ try await container.sharedCloudDatabase.deleteRecordZone(withID: zoneID)
+
+ ctx.delete(entity)
+ try ctx.save()
+ }
+
+ // MARK: - Helpers
+
+ private func fetchExistingShare(
+ recordName: String,
+ zoneName: String
+ ) async throws -> (CKShare, CKContainer) {
+ 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)
+ }
+}
diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift
@@ -8,23 +8,28 @@ extension EnvironmentValues {
@Entry var resetDatabase: (() async -> Void)? = nil
}
-/// Owns the CloudKit sync lifecycle via `CKSyncEngine`. Zone creation,
-/// subscription setup, change-token management, batching, and retry are all
-/// delegated to the framework. This actor's job is to:
+/// Owns the CloudKit sync lifecycle via two `CKSyncEngine` instances — one for
+/// the private database (owned games and shares) and one for the shared
+/// database (joined games). Zone creation, subscription setup, change-token
+/// management, batching, and retry are all delegated to the framework.
+/// This actor's job is to:
///
-/// - Start and persist the engine's state across launches.
+/// - Start and persist each engine's state across launches.
/// - Translate outbound edits (from `MoveBuffer`) into pending record zone
/// changes that CKSyncEngine will batch and send.
-/// - Apply incoming `Move` and `Snapshot` records to Core Data and replay
-/// them onto the `CellEntity` cache.
+/// - Apply incoming `Move`, `Snapshot`, and `Game` records to Core Data and
+/// replay them onto the `CellEntity` cache.
/// - Notify the main actor so the in-memory `Game` stays current.
actor SyncEngine {
let container: CKContainer
let persistence: PersistenceController
- private var engine: CKSyncEngine?
+ private var privateEngine: CKSyncEngine?
+ private var sharedEngine: CKSyncEngine?
private var onRemoteMoves: (@MainActor @Sendable ([Move]) async -> Void)?
+ private var onAccountChange: (@MainActor @Sendable () async -> Void)?
+ private var onGameAccessRevoked: (@MainActor @Sendable (UUID) async -> Void)?
private var tracer: (@MainActor @Sendable (String) -> Void)?
func setTracer(_ t: @MainActor @Sendable @escaping (String) -> Void) {
@@ -35,6 +40,14 @@ actor SyncEngine {
onRemoteMoves = cb
}
+ func setOnAccountChange(_ cb: @MainActor @Sendable @escaping () async -> Void) {
+ onAccountChange = cb
+ }
+
+ func setOnGameAccessRevoked(_ cb: @MainActor @Sendable @escaping (UUID) async -> Void) {
+ onGameAccessRevoked = cb
+ }
+
init(container: CKContainer, persistence: PersistenceController) {
self.container = container
self.persistence = persistence
@@ -42,74 +55,114 @@ actor SyncEngine {
// MARK: - Lifecycle
- /// Creates the `CKSyncEngine`, restoring previously-saved state so
- /// pending changes and change tokens survive restarts. Call once after
+ /// Creates both `CKSyncEngine` instances, restoring previously-saved state
+ /// so pending changes and change tokens survive restarts. Call once after
/// wiring callbacks.
func start() {
let bgCtx = persistence.container.newBackgroundContext()
- let saved: CKSyncEngine.State.Serialization? = bgCtx.performAndWait {
- guard let data = SyncStateEntity.current(in: bgCtx).ckEngineState else { return nil }
+
+ let privateState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait {
+ guard let data = SyncStateEntity.current(in: bgCtx).ckPrivateEngineState else {
+ return nil
+ }
return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
}
- let configuration = CKSyncEngine.Configuration(
+ privateEngine = CKSyncEngine(CKSyncEngine.Configuration(
database: container.privateCloudDatabase,
- stateSerialization: saved,
+ stateSerialization: privateState,
delegate: self
- )
- engine = CKSyncEngine(configuration)
+ ))
+
+ let sharedState: CKSyncEngine.State.Serialization? = bgCtx.performAndWait {
+ guard let data = SyncStateEntity.current(in: bgCtx).ckSharedEngineState else {
+ return nil
+ }
+ return try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: data)
+ }
+ sharedEngine = CKSyncEngine(CKSyncEngine.Configuration(
+ database: container.sharedCloudDatabase,
+ stateSerialization: sharedState,
+ delegate: self
+ ))
}
// MARK: - Outbound
/// Registers Move records as pending sends. Called by the `MoveBuffer`
- /// sink after lamports are assigned and `MoveEntity` rows are persisted.
+ /// sink after Lamports are assigned and `MoveEntity` rows are persisted.
+ /// Groups moves by game and routes each group to the correct engine.
func enqueueMoves(_ moves: [Move]) {
- guard let engine else { return }
- let zoneID = RecordSerializer.zoneID()
- engine.state.add(pendingRecordZoneChanges: moves.map { move in
- let name = RecordSerializer.recordName(forMoveInGame: move.gameID, lamport: move.lamport)
- return .saveRecord(CKRecord.ID(recordName: name, zoneID: zoneID))
- })
+ guard !moves.isEmpty else { return }
+ let grouped = Dictionary(grouping: moves, by: \.gameID)
+ let ctx = persistence.container.newBackgroundContext()
+ for (gameID, gameMoves) in grouped {
+ guard let info = zoneInfo(forGameID: gameID, in: ctx) else { continue }
+ let engine = info.scope == 1 ? sharedEngine : privateEngine
+ guard let engine else { continue }
+ engine.state.add(pendingRecordZoneChanges: gameMoves.map { move in
+ let name = RecordSerializer.recordName(forMoveInGame: move.gameID, lamport: move.lamport)
+ return .saveRecord(CKRecord.ID(recordName: name, zoneID: info.zoneID))
+ })
+ }
}
- /// Registers record deletions as pending sends. Called before the
- /// corresponding Core Data entities are deleted so their record names
- /// are still readable.
+ /// Registers record deletions as pending sends. Extracts the game UUID
+ /// from the record name and routes to the correct engine.
func enqueueDeleteRecords(_ ckRecordNames: [String]) {
- guard let engine, !ckRecordNames.isEmpty else { return }
- let zoneID = RecordSerializer.zoneID()
- engine.state.add(pendingRecordZoneChanges: ckRecordNames.map { name in
- .deleteRecord(CKRecord.ID(recordName: name, zoneID: zoneID))
- })
+ guard !ckRecordNames.isEmpty else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ let grouped = Dictionary(grouping: ckRecordNames) { name -> UUID? in
+ gameID(fromRecordName: name)
+ }
+ for (gameIDOpt, names) in grouped {
+ guard let gameID = gameIDOpt,
+ let info = zoneInfo(forGameID: gameID, in: ctx) else { continue }
+ let engine = info.scope == 1 ? sharedEngine : privateEngine
+ guard let engine else { continue }
+ engine.state.add(pendingRecordZoneChanges: names.map { name in
+ .deleteRecord(CKRecord.ID(recordName: name, zoneID: info.zoneID))
+ })
+ }
}
- /// Registers a Snapshot record as a pending send. Called after a
- /// `SnapshotEntity` has been written to Core Data.
+ /// Registers a Snapshot record as a pending send. Parses the game UUID
+ /// from the record name and routes to the correct engine.
func enqueueSnapshot(ckRecordName: String) {
+ guard let gameID = gameID(fromRecordName: ckRecordName) else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
+ let engine = info.scope == 1 ? sharedEngine : privateEngine
guard let engine else { return }
- let zoneID = RecordSerializer.zoneID()
- let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: zoneID)
+ let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
- /// Registers a Game record as a pending send. Called when a new game is
- /// created locally so the parent record exists in CloudKit before its
- /// child Move records arrive.
+ /// Registers a Game record as a pending send and ensures its zone is
+ /// created in CloudKit first. Called when a new game is created locally.
func enqueueGame(ckRecordName: String) {
+ guard let gameID = gameID(fromRecordName: ckRecordName) else { return }
+ let ctx = persistence.container.newBackgroundContext()
+ guard let info = zoneInfo(forGameID: gameID, in: ctx) else { return }
+ let engine = info.scope == 1 ? sharedEngine : privateEngine
guard let engine else { return }
- let zoneID = RecordSerializer.zoneID()
- let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: zoneID)
+ // Save the zone before the game record so it exists when records arrive.
+ engine.state.add(pendingDatabaseChanges: [.saveZone(CKRecordZone(zoneID: info.zoneID))])
+ let recordID = CKRecord.ID(recordName: ckRecordName, zoneID: info.zoneID)
engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
}
// MARK: - Explicit sync triggers (called by AppServices / diagnostics view)
func fetchChanges() async throws {
- try await engine?.fetchChanges()
+ async let p: Void = privateEngine?.fetchChanges() ?? ()
+ async let s: Void = sharedEngine?.fetchChanges() ?? ()
+ _ = try await (p, s)
}
func pushChanges() async throws {
- try await engine?.sendChanges()
+ async let p: Void = privateEngine?.sendChanges() ?? ()
+ async let s: Void = sharedEngine?.sendChanges() ?? ()
+ _ = try await (p, s)
}
// MARK: - Diagnostics
@@ -118,18 +171,35 @@ actor SyncEngine {
let accountStatus: CKAccountStatus
let engineRunning: Bool
let pendingChangesCount: Int
+ let privatePendingCount: Int
+ let sharedPendingCount: Int
+ }
+
+ /// Record names of pending `.saveRecord` changes queued on the given
+ /// scope's engine. Used by tests to verify that outbound enqueues route
+ /// to the correct database.
+ func pendingSaveRecordNames(scope: CKDatabase.Scope) -> [String] {
+ let engine = scope == .shared ? sharedEngine : privateEngine
+ guard let engine else { return [] }
+ return engine.state.pendingRecordZoneChanges.compactMap {
+ if case .saveRecord(let id) = $0 { return id.recordName }
+ return nil
+ }
}
func diagnosticSnapshot() async -> DiagnosticSnapshot {
let status: CKAccountStatus
do { status = try await container.accountStatus() }
catch { status = .couldNotDetermine }
- let running = engine != nil
- let pending = engine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
+ let running = privateEngine != nil
+ let privateCount = privateEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
+ let sharedCount = sharedEngine.map { $0.state.pendingRecordZoneChanges.count } ?? 0
return DiagnosticSnapshot(
accountStatus: status,
engineRunning: running,
- pendingChangesCount: pending
+ pendingChangesCount: privateCount + sharedCount,
+ privatePendingCount: privateCount,
+ sharedPendingCount: sharedCount
)
}
@@ -153,47 +223,101 @@ actor SyncEngine {
do {
let zones = try await container.privateCloudDatabase.allRecordZones()
let names = zones.map(\.zoneID.zoneName).joined(separator: ", ")
- results.append(("allRecordZones", "\(zones.count) zone(s): [\(names)]"))
+ results.append(("privateZones", "\(zones.count) zone(s): [\(names)]"))
+ } catch {
+ results.append(("privateZones", describe(error)))
+ }
+ do {
+ let zones = try await container.sharedCloudDatabase.allRecordZones()
+ let names = zones.map(\.zoneID.zoneName).joined(separator: ", ")
+ results.append(("sharedZones", "\(zones.count) zone(s): [\(names)]"))
} catch {
- results.append(("allRecordZones", describe(error)))
+ results.append(("sharedZones", describe(error)))
}
return results
}
- /// Clears the saved engine state so the next `start()` creates a fresh
- /// engine. Pending records already in CloudKit are unaffected.
+ /// Clears the saved state for both engines so the next `start()` creates
+ /// fresh instances. Pending records already in CloudKit are unaffected.
func resetSyncState() async {
let ctx = persistence.container.newBackgroundContext()
ctx.performAndWait {
- SyncStateEntity.current(in: ctx).ckEngineState = nil
+ let entity = SyncStateEntity.current(in: ctx)
+ entity.ckPrivateEngineState = nil
+ entity.ckSharedEngineState = nil
try? ctx.save()
}
}
// MARK: - Private helpers
+ private struct ZoneInfo {
+ let scope: Int16
+ let zoneID: CKRecordZone.ID
+ }
+
+ /// Looks up a game's scope and zone ID from Core Data. Returns `nil` if
+ /// the entity can't be found. Not `async` — uses `performAndWait` so it
+ /// can be called from non-async actor context.
+ private nonisolated func zoneInfo(
+ forGameID gameID: UUID,
+ in ctx: NSManagedObjectContext
+ ) -> ZoneInfo? {
+ ctx.performAndWait {
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
+ req.fetchLimit = 1
+ guard let entity = try? ctx.fetch(req).first else { return nil }
+ let zoneName = entity.ckZoneName ?? "game-\(gameID.uuidString)"
+ let ownerName = entity.ckZoneOwnerName ?? CKCurrentUserDefaultName
+ return ZoneInfo(
+ scope: entity.databaseScope,
+ zoneID: CKRecordZone.ID(zoneName: zoneName, ownerName: ownerName)
+ )
+ }
+ }
+
+ /// Extracts the game UUID from any of our record name formats:
+ /// `game-<UUID>`, `move-<UUID>-…`, `snapshot-<UUID>-…`.
+ private nonisolated func gameID(fromRecordName name: String) -> UUID? {
+ if name.hasPrefix("game-") {
+ return UUID(uuidString: String(name.dropFirst("game-".count)))
+ }
+ let prefix: String
+ if name.hasPrefix("move-") { prefix = "move-" }
+ else if name.hasPrefix("snapshot-") { prefix = "snapshot-" }
+ else { return nil }
+ let rest = name.dropFirst(prefix.count)
+ return UUID(uuidString: String(rest.prefix(36)))
+ }
+
private func trace(_ message: String) async {
guard let tracer else { return }
await tracer(message)
}
- private func saveEngineState(_ serialization: CKSyncEngine.State.Serialization) {
+ private func saveEngineState(
+ _ serialization: CKSyncEngine.State.Serialization,
+ isPrivate: Bool
+ ) {
let ctx = persistence.container.newBackgroundContext()
ctx.performAndWait {
guard let data = try? JSONEncoder().encode(serialization) else { return }
- SyncStateEntity.current(in: ctx).ckEngineState = data
+ let entity = SyncStateEntity.current(in: ctx)
+ if isPrivate {
+ entity.ckPrivateEngineState = data
+ } else {
+ entity.ckSharedEngineState = data
+ }
try? ctx.save()
}
}
- /// Looks up the entity for `recordID` and builds the corresponding
- /// `CKRecord`. Returns `nil` if the record no longer exists in Core Data
- /// (e.g. deleted between enqueue and send).
- private nonisolated func buildRecord(
- for recordID: CKRecord.ID,
- zoneID: CKRecordZone.ID
- ) -> CKRecord? {
+ /// Builds the `CKRecord` for a pending change. Uses the zone ID already
+ /// embedded in the `recordID` — set correctly at enqueue time.
+ private nonisolated func buildRecord(for recordID: CKRecord.ID) -> CKRecord? {
let name = recordID.recordName
+ let zoneID = recordID.zoneID
let ctx = persistence.container.newBackgroundContext()
return ctx.performAndWait {
if name.hasPrefix("game-") {
@@ -201,7 +325,7 @@ actor SyncEngine {
req.predicate = NSPredicate(format: "ckRecordName == %@", name)
req.fetchLimit = 1
guard let entity = try? ctx.fetch(req).first else { return nil }
- return Self.gameRecord(from: entity, zoneID: zoneID)
+ return Self.gameRecord(from: entity, recordID: recordID)
} else if name.hasPrefix("move-") {
let req = NSFetchRequest<MoveEntity>(entityName: "MoveEntity")
req.predicate = NSPredicate(format: "ckRecordName == %@", name)
@@ -252,10 +376,9 @@ actor SyncEngine {
private static nonisolated func gameRecord(
from entity: GameEntity,
- zoneID: CKRecordZone.ID
+ recordID: CKRecord.ID
) -> CKRecord? {
guard let ckName = entity.ckRecordName else { return nil }
- let recordID = CKRecord.ID(recordName: ckName, zoneID: zoneID)
let record: CKRecord
if let fields = entity.ckSystemFields,
let restored = RecordSerializer.decodeRecord(from: fields) {
@@ -265,12 +388,6 @@ actor SyncEngine {
}
record["title"] = entity.title as CKRecordValue?
record["completedAt"] = entity.completedAt as CKRecordValue?
- // puzzleSource is immutable after game creation. Only attach it when
- // there are no saved system fields (i.e. the record has never been
- // successfully sent). On a retry the temp file already exists at the
- // stable path, so no re-write is needed. On a re-send of an already-
- // sent record the asset is already on the server and we omit it so
- // CloudKit leaves the stored asset untouched.
if let source = entity.puzzleSource, entity.ckSystemFields == nil {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(ckName)
@@ -445,7 +562,6 @@ actor SyncEngine {
cell.letterAuthorID = gridCell.authorID
}
- // Clear cells whose position has been wiped from the log.
for (pos, cell) in byPosition where gridState[pos] == nil {
cell.letter = ""
cell.markKind = 0
@@ -456,24 +572,86 @@ actor SyncEngine {
// MARK: - Event handlers
- private func handleFetchedRecordZoneChanges(
- _ event: CKSyncEngine.Event.FetchedRecordZoneChanges
+ private func handleFetchedDatabaseChanges(
+ _ event: CKSyncEngine.Event.FetchedDatabaseChanges,
+ isPrivate: Bool
) async {
- await trace("fetch: \(event.modifications.count) modifications, \(event.deletions.count) deletions")
+ await trace(
+ "\(isPrivate ? "private" : "shared") db changes: " +
+ "\(event.modifications.count) zone mods, \(event.deletions.count) zone deletions"
+ )
- var newMoves: [Move] = []
+ guard !isPrivate else { return }
+
+ // For the shared engine, create placeholder game entities for new
+ // zones (populated fully when the Game record arrives), and mark
+ // games access-revoked when the owner removes us from the share.
let ctx = persistence.container.newBackgroundContext()
+ let revokedIDs: [UUID] = ctx.performAndWait {
+ var ids: [UUID] = []
+ for mod in event.modifications {
+ let zoneID = mod.zoneID
+ let zoneName = zoneID.zoneName
+ guard zoneName.hasPrefix("game-") else { continue }
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
+ req.fetchLimit = 1
+ if (try? ctx.fetch(req).first) == nil {
+ // Placeholder until the Game record arrives.
+ let entity = GameEntity(context: ctx)
+ let uuidString = String(zoneName.dropFirst("game-".count))
+ entity.id = UUID(uuidString: uuidString)
+ entity.ckRecordName = zoneName
+ entity.ckZoneName = zoneName
+ entity.ckZoneOwnerName = zoneID.ownerName
+ entity.databaseScope = 1
+ entity.title = "Joining\u{2026}"
+ entity.puzzleSource = ""
+ entity.createdAt = Date()
+ entity.updatedAt = Date()
+ }
+ }
+ for deletion in event.deletions {
+ let zoneName = deletion.zoneID.zoneName
+ let req = NSFetchRequest<GameEntity>(entityName: "GameEntity")
+ req.predicate = NSPredicate(format: "ckZoneName == %@", zoneName)
+ req.fetchLimit = 1
+ if let entity = try? ctx.fetch(req).first {
+ entity.isAccessRevoked = true
+ if let id = entity.id { ids.append(id) }
+ }
+ }
+ if ctx.hasChanges { try? ctx.save() }
+ return ids
+ }
- ctx.performAndWait {
+ for id in revokedIDs {
+ if let cb = onGameAccessRevoked { await cb(id) }
+ }
+ }
+
+ private func handleFetchedRecordZoneChanges(
+ _ event: CKSyncEngine.Event.FetchedRecordZoneChanges,
+ isPrivate: Bool
+ ) async {
+ let scope: Int16 = isPrivate ? 0 : 1
+ await trace(
+ "\(isPrivate ? "private" : "shared") fetch: " +
+ "\(event.modifications.count) modifications, \(event.deletions.count) deletions"
+ )
+
+ let ctx = persistence.container.newBackgroundContext()
+ let newMoves: [Move] = ctx.performAndWait {
+ var moves: [Move] = []
for mod in event.modifications {
let record = mod.record
switch record.recordType {
case "Game":
- _ = RecordSerializer.applyGameRecord(record, to: ctx)
+ _ = RecordSerializer.applyGameRecord(record, to: ctx, databaseScope: scope)
case "Move":
if let move = RecordSerializer.parseMoveRecord(record) {
self.applyMoveRecord(record, move: move, in: ctx)
- newMoves.append(move)
+ moves.append(move)
}
case "Snapshot":
if let snapshot = RecordSerializer.parseSnapshotRecord(record) {
@@ -483,7 +661,6 @@ actor SyncEngine {
break
}
}
-
for deletion in event.deletions {
self.applyDeletion(
recordID: deletion.recordID,
@@ -491,15 +668,12 @@ actor SyncEngine {
in: ctx
)
}
-
- // Replay the full move log for each affected game to keep the
- // CellEntity cache consistent with the canonical log.
- let affectedGameIDs = Set(newMoves.map { $0.gameID })
+ let affectedGameIDs = Set(moves.map { $0.gameID })
for gameID in affectedGameIDs {
self.replayCellCache(for: gameID, in: ctx)
}
-
if ctx.hasChanges { try? ctx.save() }
+ return moves
}
if let onRemoteMoves, !newMoves.isEmpty {
@@ -539,9 +713,9 @@ actor SyncEngine {
private func handleSentRecordZoneChanges(
_ event: CKSyncEngine.Event.SentRecordZoneChanges
) async {
- var failureMessages: [String] = []
let ctx = persistence.container.newBackgroundContext()
- ctx.performAndWait {
+ let failureMessages: [String] = ctx.performAndWait {
+ var messages: [String] = []
for record in event.savedRecords {
self.writeBackSystemFields(record: record, in: ctx)
}
@@ -551,11 +725,12 @@ actor SyncEngine {
let userInfo = err.userInfo
.map { "\($0.key)=\($0.value)" }
.joined(separator: " | ")
- failureMessages.append(
+ messages.append(
"send: failed to save \(name) — domain=\(err.domain) code=\(err.code) \(err.localizedDescription) | userInfo: \(userInfo)"
)
}
if ctx.hasChanges { try? ctx.save() }
+ return messages
}
for message in failureMessages {
await trace(message)
@@ -586,9 +761,6 @@ actor SyncEngine {
// MARK: - Logging helpers
private nonisolated func trace(_ message: String) {
- // nonisolated variant for use inside performAndWait; actor-isolated
- // version is above. Print to console so traces are never lost even
- // when the tracer isn't wired up yet.
print("SyncEngine: \(message)")
}
@@ -613,21 +785,20 @@ actor SyncEngine {
extension SyncEngine: CKSyncEngineDelegate {
func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
+ let isPrivate = syncEngine === privateEngine
switch event {
case .stateUpdate(let e):
- saveEngineState(e.stateSerialization)
+ saveEngineState(e.stateSerialization, isPrivate: isPrivate)
case .accountChange(let e):
await trace("account change: \(e.changeType)")
+ if let onAccountChange { await onAccountChange() }
case .fetchedDatabaseChanges(let e):
- await trace(
- "fetched database changes: \(e.modifications.count) zone modifications, " +
- "\(e.deletions.count) zone deletions"
- )
+ await handleFetchedDatabaseChanges(e, isPrivate: isPrivate)
case .fetchedRecordZoneChanges(let e):
- await handleFetchedRecordZoneChanges(e)
+ await handleFetchedRecordZoneChanges(e, isPrivate: isPrivate)
case .sentDatabaseChanges:
break
@@ -636,6 +807,7 @@ extension SyncEngine: CKSyncEngineDelegate {
await handleSentRecordZoneChanges(e)
case .willFetchChanges, .didFetchChanges,
+ .willFetchRecordZoneChanges, .didFetchRecordZoneChanges,
.willSendChanges, .didSendChanges:
break
@@ -648,12 +820,11 @@ extension SyncEngine: CKSyncEngineDelegate {
_ context: CKSyncEngine.SendChangesContext,
syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
- let zoneID = RecordSerializer.zoneID()
let pending = syncEngine.state.pendingRecordZoneChanges
guard !pending.isEmpty else { return nil }
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) { [weak self] recordID in
guard let self else { return nil }
- return self.buildRecord(for: recordID, zoneID: zoneID)
+ return self.buildRecord(for: recordID)
}
}
}
diff --git a/Crossmate/Views/CloudSharingView.swift b/Crossmate/Views/CloudSharingView.swift
@@ -0,0 +1,62 @@
+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
@@ -3,6 +3,7 @@ import SwiftUI
struct GameListView: View {
let store: GameStore
+ let shareController: ShareController
@Binding var navigationPath: NavigationPath
@Environment(\.managedObjectContext) private var viewContext
@@ -16,6 +17,9 @@ 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?
private var inProgress: [GameSummary] {
games.compactMap(GameSummary.init(entity:))
@@ -42,22 +46,7 @@ struct GameListView: View {
if !inProgress.isEmpty {
Section {
ForEach(inProgress) { game in
- GameRowView(game: game, onResume: {
- navigationPath.append(game.id)
- }, onResign: {
- resignTarget = game
- }, onDelete: {
- deleteTarget = game
- })
- .background(
- NavigationLink(value: game.id) { EmptyView() }
- .opacity(0)
- )
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- Button("Delete", role: .destructive) {
- deleteTarget = game
- }
- }
+ rowView(for: game)
}
} header: {
Text("In Progress")
@@ -67,22 +56,7 @@ struct GameListView: View {
if !completed.isEmpty {
Section {
ForEach(completed) { game in
- GameRowView(game: game, onResume: {
- navigationPath.append(game.id)
- }, onResign: {
- resignTarget = game
- }, onDelete: {
- deleteTarget = game
- })
- .background(
- NavigationLink(value: game.id) { EmptyView() }
- .opacity(0)
- )
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- Button("Delete", role: .destructive) {
- deleteTarget = game
- }
- }
+ rowView(for: game)
}
} header: {
Text("Completed")
@@ -115,6 +89,15 @@ 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 } }
@@ -130,6 +113,21 @@ struct GameListView: View {
Text("This will reveal all answers for \"\(target.title)\".")
}
}
+ .alert("Leave Puzzle?", isPresented: .init(
+ get: { leaveTarget != nil },
+ set: { if !$0 { leaveTarget = nil } }
+ )) {
+ Button("Leave", role: .destructive) {
+ if let target = leaveTarget {
+ Task { await leaveShare(game: target) }
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ if let target = leaveTarget {
+ Text("You will lose access to \"\(target.title)\".")
+ }
+ }
.alert("Delete Puzzle?", isPresented: .init(
get: { deleteTarget != nil },
set: { if !$0 { deleteTarget = nil } }
@@ -146,6 +144,37 @@ struct GameListView: View {
}
}
}
+
+ @ViewBuilder
+ private func rowView(for game: GameSummary) -> some View {
+ GameRowView(
+ game: game,
+ onResume: { navigationPath.append(game.id) },
+ onShare: { shareTarget = game },
+ onLeave: { leaveTarget = game },
+ onResign: { resignTarget = game },
+ onDelete: { deleteTarget = game }
+ )
+ .background(
+ NavigationLink(value: game.id) { EmptyView() }
+ .opacity(0)
+ )
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button("Delete", role: .destructive) {
+ deleteTarget = game
+ }
+ }
+ }
+
+ private func leaveShare(game: GameSummary) async {
+ do {
+ try await shareController.leaveShare(gameID: game.id)
+ leaveTarget = nil
+ } catch {
+ leaveError = error
+ leaveTarget = nil
+ }
+ }
}
// MARK: - Row
@@ -153,6 +182,8 @@ struct GameListView: View {
private struct GameRowView: View {
let game: GameSummary
var onResume: () -> Void = {}
+ var onShare: () -> Void = {}
+ var onLeave: () -> Void = {}
var onResign: () -> Void = {}
var onDelete: () -> Void = {}
@@ -164,8 +195,15 @@ private struct GameRowView: View {
cells: game.thumbnailCells
)
VStack(alignment: .leading, spacing: 2) {
- Text(game.title)
- .font(.headline)
+ HStack(spacing: 4) {
+ Text(game.title)
+ .font(.headline)
+ if game.isShared {
+ Image(systemName: "person.2.fill")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
if let puzzleDate = game.puzzleDate {
Text(puzzleDate, format: .dateTime.day().month(.abbreviated).year())
.font(.subheadline)
@@ -175,16 +213,13 @@ private struct GameRowView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
- Text("Solo")
- .font(.caption)
- .foregroundStyle(.secondary)
}
Spacer()
Menu {
- Button { } label: { Label("Share", systemImage: "square.and.arrow.up") }
- .disabled(true)
- Button { } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") }
- .disabled(true)
+ 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") }
Section {
Button(role: .destructive) { onResign() } label: { Label("Resign", systemImage: "flag") }
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -2,14 +2,18 @@ import SwiftUI
struct PuzzleView: View {
@Bindable var session: PlayerSession
+ var shareController: ShareController? = nil
var onComplete: (() -> Void)? = nil
@Environment(PlayerPreferences.self) private var preferences
+ @Environment(\.dismiss) private var dismiss
@State private var isRenaming = false
@State private var renameDraft = ""
@State private var showSuccessScreen = false
@State private var showErrorsAlert = false
-
- private var isShared: Bool { false }
+ @State private var isPresentingShareSheet = false
+ @State private var isConfirmingLeave = false
+ @State private var leaveError: String?
+ @State private var isRevokedBannerDismissed = false
private func swatchImage(for color: PlayerColor) -> Image {
let tint = UIColor(color.tint)
@@ -74,6 +78,12 @@ struct PuzzleView: View {
}
.background(Color(.systemGroupedBackground))
}
+ .overlay(alignment: .top) {
+ if session.mutator.isAccessRevoked && !isRevokedBannerDismissed {
+ AccessRevokedBanner { isRevokedBannerDismissed = true }
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
.background(Color(.systemBackground))
.ignoresSafeArea(.keyboard)
.toolbar {
@@ -153,13 +163,14 @@ struct PuzzleView: View {
Section {
Button("Leave Game", role: .destructive) {
- // TODO: leave shared game
+ isConfirmingLeave = true
}
- .disabled(!isShared)
+ .disabled(!(session.mutator.isShared && !session.mutator.isOwned) || shareController == nil)
Button("Share Game") {
- // TODO: present share sheet
+ isPresentingShareSheet = true
}
+ .disabled(!session.mutator.isOwned || shareController == nil)
}
} label: {
Label("Players", systemImage: "person.2")
@@ -188,6 +199,37 @@ 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() }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("You will lose access to \"\(session.puzzle.title)\".")
+ }
+ .alert(
+ "Couldn't Leave",
+ isPresented: .init(
+ get: { leaveError != nil },
+ set: { if !$0 { leaveError = nil } }
+ ),
+ presenting: leaveError
+ ) { _ in
+ Button("OK", role: .cancel) {}
+ } message: { message in
+ Text(message)
+ }
.alert("Change Name", isPresented: $isRenaming) {
TextField("Name", text: $renameDraft)
.textInputAutocapitalization(.never)
@@ -204,6 +246,16 @@ struct PuzzleView: View {
Text("Enter the name other players will see.")
}
}
+
+ private func leaveSharedGame() async {
+ guard let shareController else { return }
+ do {
+ try await shareController.leaveShare(gameID: session.mutator.gameID)
+ dismiss()
+ } catch {
+ leaveError = String(describing: error)
+ }
+ }
}
private struct ClueKey: Hashable {
@@ -351,3 +403,29 @@ private extension VerticalAlignment {
}
static let clueCenter = VerticalAlignment(ClueCenterID.self)
}
+
+private struct AccessRevokedBanner: View {
+ let onDismiss: () -> Void
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "person.slash")
+ Text("This puzzle is no longer shared with you.")
+ .font(.footnote)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Button {
+ onDismiss()
+ } label: {
+ Image(systemName: "xmark")
+ .font(.footnote.weight(.semibold))
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(Color(.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+ }
+}
diff --git a/Tests/Unit/MoveLogTests.swift b/Tests/Unit/MoveLogTests.swift
@@ -191,7 +191,7 @@ struct GridStateCodecTests {
struct RecordSerializerMoveSnapshotTests {
private let gameID = UUID(uuidString: "AABBCCDD-0000-0000-0000-111122223333")!
- private let zoneID = RecordSerializer.zoneID()
+ private var zoneID: CKRecordZone.ID { RecordSerializer.zoneID(for: gameID) }
@Test("Move record name uses the expected format")
func moveRecordNameFormat() {
@@ -276,7 +276,7 @@ struct RecordSerializerMoveSnapshotTests {
@Test("Parsing rejects records with the wrong record type")
func parseRejectsWrongRecordType() {
- let zoneID = RecordSerializer.zoneID()
+ let zoneID = RecordSerializer.zoneID(for: gameID)
let recordID = CKRecord.ID(recordName: RecordSerializer.recordName(forMoveInGame: gameID, lamport: 1), zoneID: zoneID)
let record = CKRecord(recordType: "Cell", recordID: recordID)
#expect(RecordSerializer.parseMoveRecord(record) == nil)
diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift
@@ -25,19 +25,29 @@ struct RecordSerializerTests {
#expect(a == b)
}
- // MARK: - Zone
+ // MARK: - Per-game zone
- @Test("Zone name is CrossmateZone")
- func zoneNameIsCorrect() {
- let zone = RecordSerializer.zone()
- #expect(zone.zoneID.zoneName == "CrossmateZone")
+ @Test("zoneID(for:) uses game-<UUID> as zone name")
+ func perGameZoneName() {
+ let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
+ let zone = RecordSerializer.zoneID(for: id)
+ #expect(zone.zoneName == "game-12345678-1234-1234-1234-123456789ABC")
+ #expect(zone.ownerName == CKCurrentUserDefaultName)
+ }
+
+ @Test("zoneID(for:ownerName:) accepts explicit owner")
+ func perGameZoneExplicitOwner() {
+ let id = UUID()
+ let zone = RecordSerializer.zoneID(for: id, ownerName: "alice_record_id")
+ #expect(zone.ownerName == "alice_record_id")
}
// MARK: - System fields round-trip
@Test("Encode and decode system fields preserves record type and zone")
func systemFieldsRoundTrip() {
- let zoneID = RecordSerializer.zoneID()
+ let gameID = UUID()
+ let zoneID = RecordSerializer.zoneID(for: gameID)
let recordID = CKRecord.ID(recordName: "test-record", zoneID: zoneID)
let original = CKRecord(recordType: "Cell", recordID: recordID)
@@ -47,8 +57,7 @@ struct RecordSerializerTests {
let decoded = RecordSerializer.decodeRecord(from: encoded!)
#expect(decoded != nil)
#expect(decoded?.recordType == "Cell")
- #expect(decoded?.recordID.zoneID.zoneName == "CrossmateZone")
+ #expect(decoded?.recordID.zoneID.zoneName == "game-\(gameID.uuidString)")
#expect(decoded?.recordID.recordName == "test-record")
}
}
-
diff --git a/Tests/Unit/Sync/AuthorIdentityTests.swift b/Tests/Unit/Sync/AuthorIdentityTests.swift
@@ -0,0 +1,48 @@
+import CloudKit
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("AuthorIdentity")
+struct AuthorIdentityTests {
+
+ @Test("Initial state returns nil before any refresh")
+ func initialStateIsNil() {
+ let identity = AuthorIdentity()
+ #expect(identity.currentID == nil)
+ }
+
+ @Test("currentID is readable synchronously from any context")
+ func synchronousRead() async {
+ let identity = AuthorIdentity()
+ // Simulate a refresh that writes a known value.
+ // We bypass the real CKContainer by writing directly via the actor.
+ // Since AuthorIdentity uses a lock, concurrent reads are safe.
+ let id = identity.currentID
+ #expect(id == nil)
+ }
+
+ @Test("Concurrent reads while value is nil do not crash")
+ func concurrentReadsAreSafe() async {
+ let identity = AuthorIdentity()
+ await withTaskGroup(of: String?.self) { group in
+ for _ in 0..<50 {
+ group.addTask { identity.currentID }
+ }
+ for await _ in group { }
+ }
+ }
+
+ @Test("refresh with unavailable account leaves currentID nil")
+ func refreshFailurePreservesNil() async {
+ let identity = AuthorIdentity()
+ // Using the default CKContainer without a signed-in account will fail.
+ // The contract is: on failure, currentID stays nil (or unchanged).
+ let containerWithNoAccount = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2")
+ await identity.refresh(using: containerWithNoAccount)
+ // On a simulator/CI with no account, this should stay nil.
+ // We can only assert it doesn't crash; value depends on sim state.
+ _ = identity.currentID
+ }
+}
diff --git a/Tests/Unit/Sync/PerGameZoneTests.swift b/Tests/Unit/Sync/PerGameZoneTests.swift
@@ -0,0 +1,57 @@
+import CloudKit
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("PerGameZone")
+struct PerGameZoneTests {
+
+ @Test("zoneID uses game UUID as zone name")
+ func zoneIDZoneName() {
+ let id = UUID(uuidString: "12345678-1234-1234-1234-123456789ABC")!
+ let zone = RecordSerializer.zoneID(for: id)
+ #expect(zone.zoneName == "game-12345678-1234-1234-1234-123456789ABC")
+ }
+
+ @Test("zoneID defaults to CKCurrentUserDefaultName for owner")
+ func zoneIDDefaultOwner() {
+ let id = UUID()
+ let zone = RecordSerializer.zoneID(for: id)
+ #expect(zone.ownerName == CKCurrentUserDefaultName)
+ }
+
+ @Test("zoneID accepts explicit ownerName for shared games")
+ func zoneIDExplicitOwner() {
+ let id = UUID()
+ let zone = RecordSerializer.zoneID(for: id, ownerName: "_someOwnerID")
+ #expect(zone.ownerName == "_someOwnerID")
+ }
+
+ @Test("moveRecord parent reference targets the per-game zone")
+ func moveRecordParentZone() {
+ let gameID = UUID()
+ let zoneID = RecordSerializer.zoneID(for: gameID)
+ let move = Move(
+ gameID: gameID,
+ lamport: 1,
+ row: 0, col: 0,
+ letter: "A",
+ markKind: 0,
+ checkedWrong: false,
+ authorID: nil,
+ createdAt: Date()
+ )
+ let record = RecordSerializer.moveRecord(from: move, zone: zoneID, systemFields: nil)
+ #expect(record.recordID.zoneID.zoneName == "game-\(gameID.uuidString)")
+ #expect(record.parent?.recordID.zoneID.zoneName == "game-\(gameID.uuidString)")
+ }
+
+ @Test("recordName(forGameID:) embedded in zoneID zoneName")
+ func gameRecordNameMatchesZoneName() {
+ let id = UUID()
+ let zoneName = RecordSerializer.zoneID(for: id).zoneName
+ let recordName = RecordSerializer.recordName(forGameID: id)
+ #expect(zoneName == recordName)
+ }
+}
diff --git a/Tests/Unit/Sync/ShareRoutingTests.swift b/Tests/Unit/Sync/ShareRoutingTests.swift
@@ -0,0 +1,119 @@
+import CloudKit
+import CoreData
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+/// Verifies that `SyncEngine.enqueueMoves` routes to the correct engine based
+/// on each game's `databaseScope`. Uses a real in-memory persistence store and
+/// inspects each engine's pending-changes queue after enqueueing. No iCloud
+/// account is required — `CKSyncEngine.init` and its local state APIs work
+/// offline; only `fetchChanges` / `sendChanges` need an account.
+@Suite("ShareRouting", .serialized)
+@MainActor
+struct ShareRoutingTests {
+
+ private func makeEngineWithGames() async throws -> (SyncEngine, UUID, UUID) {
+ let persistence = makeTestPersistence()
+ let ctx = persistence.viewContext
+
+ let privateID = UUID()
+ let privateEntity = GameEntity(context: ctx)
+ privateEntity.id = privateID
+ privateEntity.title = "Private"
+ privateEntity.puzzleSource = ""
+ privateEntity.createdAt = Date()
+ privateEntity.updatedAt = Date()
+ privateEntity.ckRecordName = "game-\(privateID.uuidString)"
+ privateEntity.ckZoneName = "game-\(privateID.uuidString)"
+ privateEntity.databaseScope = 0
+
+ let sharedID = UUID()
+ let sharedEntity = GameEntity(context: ctx)
+ sharedEntity.id = sharedID
+ sharedEntity.title = "Shared"
+ sharedEntity.puzzleSource = ""
+ sharedEntity.createdAt = Date()
+ sharedEntity.updatedAt = Date()
+ sharedEntity.ckRecordName = "game-\(sharedID.uuidString)"
+ sharedEntity.ckZoneName = "game-\(sharedID.uuidString)"
+ sharedEntity.ckZoneOwnerName = "_someOtherUser"
+ sharedEntity.databaseScope = 1
+
+ try ctx.save()
+
+ let container = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2")
+ let engine = SyncEngine(container: container, persistence: persistence)
+ await engine.start()
+
+ return (engine, privateID, sharedID)
+ }
+
+ private func move(in gameID: UUID, lamport: Int64, letter: String) -> Move {
+ Move(
+ gameID: gameID,
+ lamport: lamport,
+ row: 0, col: 0,
+ letter: letter,
+ markKind: 0,
+ checkedWrong: false,
+ authorID: nil,
+ createdAt: Date()
+ )
+ }
+
+ @Test("Private-game moves land on the private engine only")
+ func privateMoveEnqueue() async throws {
+ let (engine, privateID, _) = try await makeEngineWithGames()
+ await engine.enqueueMoves([move(in: privateID, lamport: 1, letter: "A")])
+
+ let privateNames = await engine.pendingSaveRecordNames(scope: .private)
+ let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
+
+ #expect(privateNames.contains { $0.hasPrefix("move-\(privateID.uuidString)") })
+ #expect(sharedNames.isEmpty)
+ }
+
+ @Test("Shared-game moves land on the shared engine only")
+ func sharedMoveEnqueue() async throws {
+ let (engine, _, sharedID) = try await makeEngineWithGames()
+ await engine.enqueueMoves([move(in: sharedID, lamport: 1, letter: "B")])
+
+ let privateNames = await engine.pendingSaveRecordNames(scope: .private)
+ let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
+
+ #expect(sharedNames.contains { $0.hasPrefix("move-\(sharedID.uuidString)") })
+ #expect(privateNames.isEmpty)
+ }
+
+ @Test("Mixed-scope batch fans out to the correct engines")
+ func mixedScopeEnqueue() async throws {
+ let (engine, privateID, sharedID) = try await makeEngineWithGames()
+ await engine.enqueueMoves([
+ move(in: privateID, lamport: 1, letter: "A"),
+ move(in: sharedID, lamport: 1, letter: "B"),
+ ])
+
+ let privateNames = await engine.pendingSaveRecordNames(scope: .private)
+ let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
+
+ #expect(privateNames.contains { $0.hasPrefix("move-\(privateID.uuidString)") })
+ #expect(!privateNames.contains { $0.hasPrefix("move-\(sharedID.uuidString)") })
+ #expect(sharedNames.contains { $0.hasPrefix("move-\(sharedID.uuidString)") })
+ #expect(!sharedNames.contains { $0.hasPrefix("move-\(privateID.uuidString)") })
+ }
+
+ @Test("Unknown game IDs enqueue nothing")
+ func unknownGameIsDropped() async throws {
+ let (engine, _, _) = try await makeEngineWithGames()
+ let orphan = UUID()
+ await engine.enqueueMoves([move(in: orphan, lamport: 1, letter: "Z")])
+
+ let privateNames = await engine.pendingSaveRecordNames(scope: .private)
+ let sharedNames = await engine.pendingSaveRecordNames(scope: .shared)
+
+ #expect(privateNames.isEmpty)
+ #expect(sharedNames.isEmpty)
+ }
+}
diff --git a/project.yml b/project.yml
@@ -53,6 +53,7 @@ targets:
LSHandlerRank: Owner
LSItemContentTypes:
- net.inqk.crossmate.xd
+ CKSharingSupported: true
LSSupportsOpeningDocumentsInPlace: false
UILaunchScreen: {}
UISupportedInterfaceOrientations: