crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 32++++++++++++++++++++++++++++++++
MCrossmate/CrossmateApp.swift | 20++++++++++++++++++--
MCrossmate/Info.plist | 2++
MCrossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents | 7++++++-
MCrossmate/Persistence/GameMutator.swift | 34++++++++++++++++++++++++++++++----
MCrossmate/Persistence/GameStore.swift | 35++++++++++++++++++++++++++++++++++-
MCrossmate/Services/AppServices.swift | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
ACrossmate/Sync/AuthorIdentity.swift | 24++++++++++++++++++++++++
MCrossmate/Sync/RecordSerializer.swift | 24++++++++++++++++--------
ACrossmate/Sync/ShareController.swift | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 361++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
ACrossmate/Views/CloudSharingView.swift | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameListView.swift | 117+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
MCrossmate/Views/PuzzleView.swift | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
MTests/Unit/MoveLogTests.swift | 4++--
MTests/Unit/RecordSerializerTests.swift | 25+++++++++++++++++--------
ATests/Unit/Sync/AuthorIdentityTests.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/PerGameZoneTests.swift | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/ShareRoutingTests.swift | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mproject.yml | 1+
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: