crossmate

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

commit a150c6e718ed13657b626dc8734dc00d7543e291
parent 8b65e9e554b3fa9a74cd2bef642989491469bf65
Author: Michael Camilleri <[email protected]>
Date:   Sun, 26 Apr 2026 20:03:16 +0900

Extract CloudService and ImportService from AppServices

AppServices had been accumulating responsibilities beyond service wiring —
direct CloudKit calls for share acceptance and zone teardown, plus
security-scoped URL handling for .xd file imports. This commit moves those
operations into two dedicated services so AppServices can focus on constructing
and starting the service graph.

CloudService owns the CloudKit-facing work that previously lived on
AppServices: acceptShare runs CKAcceptSharesOperation and kicks the shared
engine to fetch the new zone, and resetAllData tears down private zones, leaves
shared zones, and clears the local store. ImportService owns the .xd open-URL
flow, including security-scoped resource access and the Drive import sidecar.

The run helper that wrapped recordStart/recordSuccess/recordError around an
async block moves from a static method on AppServices onto SyncMonitor
itself. AppServices and SyncDiagnosticsView now call syncMonitor.run(...)
directly, and CloudService.acceptShare uses the same helper instead of
open-coding the pattern.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/CrossmateApp.swift | 4++--
MCrossmate/Services/AppServices.swift | 130++++++++++----------------------------------------------------------------------
ACrossmate/Services/CloudService.swift | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/ImportService.swift | 39+++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncMonitor.swift | 10++++++++++
MCrossmate/Views/SyncDiagnosticsView.swift | 4++--
7 files changed, 161 insertions(+), 119 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; + 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; @@ -56,6 +57,7 @@ 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 */; }; + CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BC76178319D0D669CD50FF /* CloudService.swift */; }; CCF2B0EBE9F414B5CA6AADBA /* NameBroadcaster.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */; }; CE033A7502E71066DB51EF0D /* SyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC00F4D5060EDA4859A6B3F1 /* SyncMonitor.swift */; }; CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C0A7348E1283E7CD2486E2A /* RecordSerializer.swift */; }; @@ -103,12 +105,14 @@ 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>"; }; + 462CE0FD356F6137C9BFD30F /* ImportService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportService.swift; sourceTree = "<group>"; }; 465F2BB469EFE84CF3733398 /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcaster.swift; sourceTree = "<group>"; }; 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCatalog.swift; sourceTree = "<group>"; }; 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; }; 52B8E26067849A63758DDEA4 /* MoveBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveBuffer.swift; sourceTree = "<group>"; }; 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoveLogTests.swift; sourceTree = "<group>"; }; + 56BC76178319D0D669CD50FF /* CloudService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudService.swift; sourceTree = "<group>"; }; 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>"; }; @@ -315,7 +319,9 @@ isa = PBXGroup; children = ( CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */, + 56BC76178319D0D669CD50FF /* CloudService.swift */, 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */, + 462CE0FD356F6137C9BFD30F /* ImportService.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, @@ -458,6 +464,7 @@ C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, + CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */, EE7DE19BE6F788B8D3D60DF2 /* CloudSharingView.swift in Sources */, B94919176DEC6EC31637B037 /* ClueList.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, @@ -470,6 +477,7 @@ D58980B92C99122C368D4216 /* GameStore.swift in Sources */, C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, + 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */, 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -23,7 +23,7 @@ struct CrossmateApp: App { .environment(\.syncEngine, services.syncEngine) .environment(services.nytAuth) .environment(\.nytPuzzleFetcher, services.nytFetcher) - .environment(\.resetDatabase, { await services.resetAllData() }) + .environment(\.resetDatabase, { await services.cloudService.resetAllData() }) } } } @@ -89,7 +89,7 @@ struct RootView: View { await services.start(appDelegate: appDelegate, preferences: preferences) } .onOpenURL { url in - if let id = services.handleOpenURL(url) { + if let id = services.importService.importGame(from: url) { navigationPath.append(id) } } diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -14,6 +14,8 @@ final class AppServices { let identity: AuthorIdentity let shareController: ShareController let colorStore: GamePlayerColorStore + let cloudService: CloudService + let importService: ImportService private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") private var started = false @@ -66,6 +68,13 @@ final class AppServices { colorStore: colorStore ) self.colorStore = colorStore + self.cloudService = CloudService( + container: self.ckContainer, + syncEngine: syncEngine, + syncMonitor: self.syncMonitor, + store: store + ) + self.importService = ImportService(store: store, driveMonitor: self.driveMonitor) } func start(appDelegate: AppDelegate, preferences: PlayerPreferences) async { @@ -79,7 +88,7 @@ final class AppServices { await self.handleRemoteNotification() } appDelegate.onAcceptShare = { metadata in - await self.acceptShare(metadata: metadata) + await self.cloudService.acceptShare(metadata: metadata) } await syncEngine.setTracer { [syncMonitor] message in @@ -123,10 +132,10 @@ final class AppServices { await syncEngine.start() - await Self.run("initial fetch", monitor: syncMonitor) { + await syncMonitor.run("initial fetch") { try await syncEngine.fetchChanges() } - await Self.run("initial push", monitor: syncMonitor) { + await syncMonitor.run("initial push") { try await syncEngine.pushChanges() } await refreshSnapshot() @@ -134,10 +143,10 @@ final class AppServices { func syncOnForeground() async { await moveBuffer.flush() - await Self.run("foreground fetch", monitor: syncMonitor) { + await syncMonitor.run("foreground fetch") { try await syncEngine.fetchChanges() } - await Self.run("foreground push", monitor: syncMonitor) { + await syncMonitor.run("foreground push") { try await syncEngine.pushChanges() } await refreshSnapshot() @@ -147,46 +156,6 @@ final class AppServices { await moveBuffer.flush() } - /// 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() - UserDefaults.standard.removeObject(forKey: "gamePlayerColors") - syncMonitor.note("Database reset — all games and sync state cleared") - } - func makePlayerRoster(for gameID: UUID, preferences: PlayerPreferences) -> PlayerRoster { PlayerRoster( gameID: gameID, @@ -198,35 +167,8 @@ final class AppServices { ) } - func handleOpenURL(_ url: URL) -> UUID? { - guard url.pathExtension.lowercased() == "xd" else { return nil } - - let needsAccess = url.startAccessingSecurityScopedResource() - defer { - if needsAccess { - url.stopAccessingSecurityScopedResource() - } - } - - let source: String - do { - source = try String(contentsOf: url, encoding: .utf8) - } catch { - return nil - } - - if driveMonitor.containerAvailable { - try? driveMonitor.importFile(from: url) - } - - if let existing = store.findGameID(matching: source) { - return existing - } - return try? store.createGame(from: source) - } - private func handleRemoteNotification() async { - await Self.run("remote-notification fetch", monitor: syncMonitor) { + await syncMonitor.run("remote-notification fetch") { try await syncEngine.fetchChanges() } } @@ -236,35 +178,6 @@ 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)") - } - } - /// Builds the `GameStore.onGameDeleted` callback. Extracted so tests can /// drive the exact same closure that production wires up — keeps the /// colour-cleanup branch from drifting silently. @@ -281,17 +194,4 @@ final class AppServices { } } - static func run( - _ phase: String, - monitor: SyncMonitor, - _ body: @Sendable () async throws -> Void - ) async { - monitor.recordStart(phase) - do { - try await body() - monitor.recordSuccess(phase) - } catch { - monitor.recordError(phase, error) - } - } } diff --git a/Crossmate/Services/CloudService.swift b/Crossmate/Services/CloudService.swift @@ -0,0 +1,85 @@ +import CloudKit + +@MainActor +final class CloudService { + private let ckContainer: CKContainer + private let syncEngine: SyncEngine + private let syncMonitor: SyncMonitor + private let store: GameStore + + init( + container: CKContainer, + syncEngine: SyncEngine, + syncMonitor: SyncMonitor, + store: GameStore + ) { + self.ckContainer = container + self.syncEngine = syncEngine + self.syncMonitor = syncMonitor + self.store = store + } + + 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 syncMonitor.run("share-accept fetch") { + try await syncEngine.fetchChanges() + } + } catch { + syncMonitor.recordError("acceptShare", error) + } + } + + func resetAllData() async { + await syncEngine.resetSyncState() + + async let privateCleanup: Void = deleteAllPrivateZones() + async let sharedCleanup: Void = leaveAllSharedZones() + _ = await (privateCleanup, sharedCleanup) + + store.resetAllData() + UserDefaults.standard.removeObject(forKey: "gamePlayerColors") + syncMonitor.note("Database reset — all games and sync state cleared") + } + + 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)") + } + } +} diff --git a/Crossmate/Services/ImportService.swift b/Crossmate/Services/ImportService.swift @@ -0,0 +1,39 @@ +import Foundation + +@MainActor +final class ImportService { + private let store: GameStore + private let driveMonitor: DriveMonitor + + init(store: GameStore, driveMonitor: DriveMonitor) { + self.store = store + self.driveMonitor = driveMonitor + } + + func importGame(from url: URL) -> UUID? { + guard url.pathExtension.lowercased() == "xd" else { return nil } + + let needsAccess = url.startAccessingSecurityScopedResource() + defer { + if needsAccess { + url.stopAccessingSecurityScopedResource() + } + } + + let source: String + do { + source = try String(contentsOf: url, encoding: .utf8) + } catch { + return nil + } + + if driveMonitor.containerAvailable { + try? driveMonitor.importFile(from: url) + } + + if let existing = store.findGameID(matching: source) { + return existing + } + return try? store.createGame(from: source) + } +} diff --git a/Crossmate/Sync/SyncMonitor.swift b/Crossmate/Sync/SyncMonitor.swift @@ -59,6 +59,16 @@ final class SyncMonitor { lastErrorDescription = nil } + func run(_ phase: String, _ body: @Sendable () async throws -> Void) async { + recordStart(phase) + do { + try await body() + recordSuccess(phase) + } catch { + recordError(phase, error) + } + } + private func append(level: String, _ message: String) { entries.append(SyncDiagnosticEntry(timestamp: Date(), level: level, message: message)) if entries.count > maxEntries { diff --git a/Crossmate/Views/SyncDiagnosticsView.swift b/Crossmate/Views/SyncDiagnosticsView.swift @@ -121,10 +121,10 @@ struct SyncDiagnosticsView: View { isSyncing = true defer { isSyncing = false } - await AppServices.run("manual fetch", monitor: syncMonitor) { + await syncMonitor.run("manual fetch") { try await syncEngine.fetchChanges() } - await AppServices.run("manual push", monitor: syncMonitor) { + await syncMonitor.run("manual push") { try await syncEngine.pushChanges() } let snapshot = await syncEngine.diagnosticSnapshot()