crossmate

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

commit 389d8cd6a538abda8495edf14b89d8910cbbaaff
parent 60a0c7da7488a57bf3e5becce99a642ca14859a1
Author: Michael Camilleri <[email protected]>
Date:   Fri, 17 Apr 2026 12:24:40 +0900

Improve separation of concerns in architecture

Prior to this commit, a number of sync-related work was being done in
view-related files. This commit moves more of that to an `AppServices`
object. It also improves synchronisation of user preferences between
devices.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 16++++++++++++----
MCrossmate/Crossmate.entitlements | 2++
MCrossmate/CrossmateApp.swift | 179+++++++++++--------------------------------------------------------------------
MCrossmate/Models/PlayerColor.swift | 4----
ACrossmate/Models/PlayerPreferences.swift | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Services/AppServices.swift | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/CellView.swift | 3++-
MCrossmate/Views/GameListView.swift | 1-
MCrossmate/Views/PuzzleView.swift | 22+++++++++++-----------
MCrossmate/Views/SyncDiagnosticsView.swift | 6+++---
10 files changed, 265 insertions(+), 180 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -14,9 +14,9 @@ 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; - F1A11234567890ABCDEF0001 /* OutboxRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B11234567890ABCDEF0001 /* OutboxRecorder.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; + 4A99624B75CDD821BF173621 /* OutboxRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; 5AB52A0BA2934922EB94E5D1 /* UbiquityMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9876B49C8BE966CC2A37BF52 /* UbiquityMonitor.swift */; }; @@ -24,6 +24,7 @@ 6BFB5945FCCDEC64C431C2AC /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC295195602B3DDF7BB3895 /* PersistenceController.swift */; }; + 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; @@ -55,6 +56,7 @@ ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.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 */; }; + F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */; }; FFBE2EC8A3A60E119A0D314F /* NYTBrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */; }; /* End PBXBuildFile section */ @@ -80,7 +82,7 @@ 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>"; }; - F1B11234567890ABCDEF0001 /* OutboxRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxRecorder.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>"; }; 50992CDA4082429EBB17F65C /* garden.xd */ = {isa = PBXFileReference; path = garden.xd; sourceTree = "<group>"; }; @@ -111,6 +113,7 @@ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; }; CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; + CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; }; @@ -120,6 +123,7 @@ E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PendingChange+Helpers.swift"; sourceTree = "<group>"; }; E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; + F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutboxRecorder.swift; sourceTree = "<group>"; }; F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGameSheet.swift; sourceTree = "<group>"; }; F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; @@ -173,6 +177,7 @@ B135C285570F91181595B405 /* CellMark.swift */, 465F2BB469EFE84CF3733398 /* Game.swift */, DB55FC337CF72C650373210A /* PlayerColor.swift */, + 46148CF0F4D719692F81A6EC /* PlayerPreferences.swift */, 20B331CC55827FEF3420ABCE /* PlayerSession.swift */, 64C8064F04FC6177D987ACA2 /* Puzzle.swift */, 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */, @@ -190,7 +195,7 @@ children = ( 43DC132D49361C56DE79C13E /* GameMutator.swift */, 93EE5BA78566EDED68D846AB /* GameStore.swift */, - F1B11234567890ABCDEF0001 /* OutboxRecorder.swift */, + F13AB28AA016F8A3DF53E6AA /* OutboxRecorder.swift */, E524780E360E008FACE4F213 /* PendingChange+Helpers.swift */, ACC295195602B3DDF7BB3895 /* PersistenceController.swift */, ); @@ -257,6 +262,7 @@ D8F0E3376B2616B4E917129C /* Services */ = { isa = PBXGroup; children = ( + CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, B0938B0ACB40772EE522D77C /* NYTPuzzleFetcher.swift */, @@ -384,6 +390,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */, AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, C944A5BD871C6ECC64DE8A5B /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, @@ -394,7 +401,6 @@ 818B1F2693962832BE14578E /* GameListView.swift in Sources */, 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */, D58980B92C99122C368D4216 /* GameStore.swift in Sources */, - F1A11234567890ABCDEF0001 /* OutboxRecorder.swift in Sources */, C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, @@ -406,10 +412,12 @@ 0241DC498C645FE1BDA00FB0 /* NYTPuzzleFetcher.swift in Sources */, B762200F54C52E8377A80D15 /* NYTToXDConverter.swift in Sources */, DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */, + 4A99624B75CDD821BF173621 /* OutboxRecorder.swift in Sources */, D66C1A4FDEA5E912E00FB742 /* PendingChange+Helpers.swift in Sources */, C6E0E5128565D3B822A41605 /* PendingChangePayload.swift in Sources */, 77556FD9473A3F10FADF5E4E /* PersistenceController.swift in Sources */, 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */, + F8DDA34AC1A6B6499C5D222E /* PlayerPreferences.swift in Sources */, 8F5CB2F94E083D06D7E04280 /* PlayerSession.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, diff --git a/Crossmate/Crossmate.entitlements b/Crossmate/Crossmate.entitlements @@ -19,5 +19,7 @@ <string>iCloud.net.inqk.crossmate.v2</string> <string>iCloud.net.inqk.crossmate</string> </array> + <key>com.apple.developer.ubiquity-kvstore-identifier</key> + <string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string> </dict> </plist> diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -1,45 +1,25 @@ -import CloudKit import SwiftUI @main struct CrossmateApp: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate - @State private var store: GameStore - @State private var syncEngine: SyncEngine - @State private var syncMonitor = SyncMonitor() - @State private var nytAuth = NYTAuthService() - @State private var ubiquityMonitor = UbiquityMonitor() - private let persistence: PersistenceController - private let outboxRecorder: OutboxRecorder - private let nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } + @State private var services: AppServices init() { - let persistence = PersistenceController() - let outboxRecorder = OutboxRecorder(persistence: persistence) - let store = GameStore(persistence: persistence) - let engine = SyncEngine( - container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v2"), - persistence: persistence - ) - self.persistence = persistence - self.outboxRecorder = outboxRecorder - self._store = State(initialValue: store) - self._syncEngine = State(initialValue: engine) + self._services = State(initialValue: AppServices()) } var body: some Scene { WindowGroup { RootView( - store: store, - syncEngine: syncEngine, - syncMonitor: syncMonitor, + services: services, appDelegate: appDelegate ) - .environment(\.managedObjectContext, persistence.viewContext) - .environment(nytAuth) - .environment(ubiquityMonitor) - .environment(\.nytPuzzleFetcher, nytFetcher) + .environment(\.managedObjectContext, services.persistence.viewContext) + .environment(services.nytAuth) + .environment(services.ubiquityMonitor) + .environment(\.nytPuzzleFetcher, services.nytFetcher) } } } @@ -69,171 +49,58 @@ final class AppDelegate: NSObject, UIApplicationDelegate, @unchecked Sendable { // MARK: - Root View struct RootView: View { - let store: GameStore - let syncEngine: SyncEngine - let syncMonitor: SyncMonitor + let services: AppServices let appDelegate: AppDelegate - @Environment(NYTAuthService.self) private var nytAuth - @Environment(UbiquityMonitor.self) private var ubiquityMonitor @Environment(\.scenePhase) private var scenePhase - @AppStorage("playerColorID") private var playerColorID: String = PlayerColor.blue.id - @State private var syncBootstrapped = false + @State private var preferences = PlayerPreferences() @State private var navigationPath = NavigationPath() var body: some View { NavigationStack(path: $navigationPath) { GameListView( - store: store, - syncEngine: syncEngine, - syncMonitor: syncMonitor, - appDelegate: appDelegate, + store: services.store, + syncEngine: services.syncEngine, + syncMonitor: services.syncMonitor, navigationPath: $navigationPath ) .navigationDestination(for: UUID.self) { gameID in - GameDestinationView( + PuzzleDisplayView( gameID: gameID, - store: store, - syncEngine: syncEngine, - syncMonitor: syncMonitor + store: services.store ) } } - .environment(\.playerColor, PlayerColor.color(for: playerColorID)) + .environment(preferences) .task { - guard !syncBootstrapped else { return } - syncBootstrapped = true - - nytAuth.loadStoredSession() - ubiquityMonitor.start() - - // Wire local outbox changes → sync engine push - store.onPendingChangesAvailable = { [syncEngine, syncMonitor] in - Task { - await Self.run("local pending push", monitor: syncMonitor) { - try await syncEngine.pushChanges() - } - } - } - - // Wire app delegate → sync engine fetch - appDelegate.onRemoteNotification = { [syncEngine, syncMonitor] in - await Self.run("remote-notification fetch", monitor: syncMonitor) { - try await syncEngine.fetchChanges() - } - } - - // Wire sync engine → store → mutator (single inbox) - await syncEngine.setOnRemoteCellChanges { [store] changes in - store.applyRemoteChanges(changes) - } - - // Wire sync engine traces → diagnostics view - await syncEngine.setTracer { [syncMonitor] message in - syncMonitor.note(message) - } - - // Start observing iCloud account changes so sign-in / sign-out - // events trigger a re-sync without an app relaunch. - await syncEngine.startAccountObserver() - - // Bootstrap and initial sync - await Self.run("bootstrap", monitor: syncMonitor) { - try await syncEngine.bootstrap() - } - await Self.run("initial fetch", monitor: syncMonitor) { - try await syncEngine.fetchChanges() - } - await Self.run("initial push", monitor: syncMonitor) { - try await syncEngine.pushChanges() - } - await refreshSnapshot() + await services.start(appDelegate: appDelegate) } .onOpenURL { url in - handleOpenURL(url) + if let id = services.handleOpenURL(url) { + navigationPath.append(id) + } } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { - Task { - await Self.run("foreground fetch", monitor: syncMonitor) { - try await syncEngine.fetchChanges() - } - await Self.run("foreground push", monitor: syncMonitor) { - try await syncEngine.pushChanges() - } - await refreshSnapshot() - } + Task { await services.syncOnForeground() } } } } - - private func handleOpenURL(_ url: URL) { - guard url.pathExtension.lowercased() == "xd" else { return } - - let needsAccess = url.startAccessingSecurityScopedResource() - defer { - if needsAccess { - url.stopAccessingSecurityScopedResource() - } - } - - let source: String - do { - source = try String(contentsOf: url, encoding: .utf8) - } catch { - return - } - - if ubiquityMonitor.containerAvailable { - try? ubiquityMonitor.importFile(from: url) - } - - if let existing = store.findGameID(matching: source) { - navigationPath.append(existing) - return - } - if let id = try? store.createGame(from: source) { - navigationPath.append(id) - } - } - - private func refreshSnapshot() async { - let snapshot = await syncEngine.diagnosticSnapshot() - syncMonitor.updateSnapshot(snapshot) - } - - static func run( - _ phase: String, - monitor: SyncMonitor, - _ body: @Sendable () async throws -> Void - ) async { - await MainActor.run { monitor.recordStart(phase) } - do { - try await body() - await MainActor.run { monitor.recordSuccess(phase) } - } catch { - await MainActor.run { monitor.recordError(phase, error) } - } - } } // MARK: - Game Destination -/// Loads a game when navigated to and sets up sync wiring for it. -private struct GameDestinationView: View { +/// Loads a game when navigated to. +private struct PuzzleDisplayView: View { let gameID: UUID let store: GameStore - let syncEngine: SyncEngine - let syncMonitor: SyncMonitor @State private var session: PlayerSession? @State private var loadError: String? - init(gameID: UUID, store: GameStore, syncEngine: SyncEngine, syncMonitor: SyncMonitor) { + init(gameID: UUID, store: GameStore) { self.gameID = gameID self.store = store - self.syncEngine = syncEngine - self.syncMonitor = syncMonitor do { let (game, mutator) = try store.loadGame(id: gameID) diff --git a/Crossmate/Models/PlayerColor.swift b/Crossmate/Models/PlayerColor.swift @@ -44,7 +44,3 @@ extension PlayerColor { } } -extension EnvironmentValues { - /// The local player's chosen highlight colour. Defaults to blue. - @Entry var playerColor: PlayerColor = .blue -} diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift @@ -0,0 +1,75 @@ +import Observation +import SwiftUI + +/// Local, per-device player preferences (colour and display name). +/// +/// Persistence is layered: values are written to `UserDefaults` for fast local +/// reads and to `NSUbiquitousKeyValueStore` so they follow the user across +/// their devices. On launch we prefer the cloud copy if present, falling back +/// to the local copy, then to defaults. External changes (from another +/// device) are observed and applied live. +/// +/// This object is anchored in `RootView`'s `@State` and lives for the app's +/// lifetime; no `deinit` / observer teardown is required. +@Observable +@MainActor +final class PlayerPreferences { + private enum Keys { + static let colorID = "playerColorID" + static let name = "playerName" + } + + private let local: UserDefaults + private let cloud: NSUbiquitousKeyValueStore + + var colorID: String { + didSet { write(Keys.colorID, colorID) } + } + + var name: String { + didSet { write(Keys.name, name) } + } + + var color: PlayerColor { + get { PlayerColor.color(for: colorID) } + set { colorID = newValue.id } + } + + init( + local: UserDefaults = .standard, + cloud: NSUbiquitousKeyValueStore = .default + ) { + self.local = local + self.cloud = cloud + self.colorID = cloud.string(forKey: Keys.colorID) + ?? local.string(forKey: Keys.colorID) + ?? PlayerColor.blue.id + self.name = cloud.string(forKey: Keys.name) + ?? local.string(forKey: Keys.name) + ?? "Player" + cloud.synchronize() + NotificationCenter.default.addObserver( + forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, + object: cloud, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.pullFromCloud() + } + } + } + + private func write(_ key: String, _ value: String) { + local.set(value, forKey: key) + cloud.set(value, forKey: key) + } + + private func pullFromCloud() { + if let id = cloud.string(forKey: Keys.colorID), id != colorID { + colorID = id + } + if let newName = cloud.string(forKey: Keys.name), newName != name { + name = newName + } + } +} diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -0,0 +1,137 @@ +import CloudKit +import Foundation + +@MainActor +final class AppServices { + let persistence: PersistenceController + let store: GameStore + let syncEngine: SyncEngine + let syncMonitor: SyncMonitor + let nytAuth: NYTAuthService + let ubiquityMonitor: UbiquityMonitor + let nytFetcher: NYTPuzzleFetcher + + private let outboxRecorder: OutboxRecorder + private var started = false + + init() { + self.persistence = PersistenceController() + self.outboxRecorder = OutboxRecorder(persistence: persistence) + self.store = GameStore(persistence: persistence) + self.syncEngine = SyncEngine( + container: CKContainer(identifier: "iCloud.net.inqk.crossmate.v2"), + persistence: persistence + ) + self.syncMonitor = SyncMonitor() + self.nytAuth = NYTAuthService() + self.ubiquityMonitor = UbiquityMonitor() + self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } + } + + func start(appDelegate: AppDelegate) async { + guard !started else { return } + started = true + + nytAuth.loadStoredSession() + ubiquityMonitor.start() + + store.onPendingChangesAvailable = { + Task { + await self.pushPendingChanges() + } + } + + appDelegate.onRemoteNotification = { + await self.handleRemoteNotification() + } + + await syncEngine.setOnRemoteCellChanges { [store] changes in + store.applyRemoteChanges(changes) + } + + await syncEngine.setTracer { [syncMonitor] message in + syncMonitor.note(message) + } + + await syncEngine.startAccountObserver() + + await Self.run("bootstrap", monitor: syncMonitor) { + try await syncEngine.bootstrap() + } + await Self.run("initial fetch", monitor: syncMonitor) { + try await syncEngine.fetchChanges() + } + await Self.run("initial push", monitor: syncMonitor) { + try await syncEngine.pushChanges() + } + await refreshSnapshot() + } + + func syncOnForeground() async { + await Self.run("foreground fetch", monitor: syncMonitor) { + try await syncEngine.fetchChanges() + } + await Self.run("foreground push", monitor: syncMonitor) { + try await syncEngine.pushChanges() + } + await refreshSnapshot() + } + + 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 ubiquityMonitor.containerAvailable { + try? ubiquityMonitor.importFile(from: url) + } + + if let existing = store.findGameID(matching: source) { + return existing + } + return try? store.createGame(from: source) + } + + private func pushPendingChanges() async { + await Self.run("local pending push", monitor: syncMonitor) { + try await syncEngine.pushChanges() + } + } + + private func handleRemoteNotification() async { + await Self.run("remote-notification fetch", monitor: syncMonitor) { + try await syncEngine.fetchChanges() + } + } + + private func refreshSnapshot() async { + let snapshot = await syncEngine.diagnosticSnapshot() + syncMonitor.updateSnapshot(snapshot) + } + + 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/Views/CellView.swift b/Crossmate/Views/CellView.swift @@ -8,7 +8,8 @@ struct CellView: View { let isHighlighted: Bool let specialKind: Puzzle.Special? - @Environment(\.playerColor) private var playerColor + @Environment(PlayerPreferences.self) private var preferences + private var playerColor: PlayerColor { preferences.color } var body: some View { ZStack(alignment: .topLeading) { diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -5,7 +5,6 @@ struct GameListView: View { let store: GameStore let syncEngine: SyncEngine let syncMonitor: SyncMonitor - let appDelegate: AppDelegate @Binding var navigationPath: NavigationPath @Environment(\.managedObjectContext) private var viewContext diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -2,9 +2,7 @@ import SwiftUI struct PuzzleView: View { @Bindable var session: PlayerSession - @Environment(\.playerColor) private var playerColor - @AppStorage("playerColorID") private var playerColorID: String = PlayerColor.blue.id - @AppStorage("playerName") private var playerName: String = "Player" + @Environment(PlayerPreferences.self) private var preferences @State private var isRenaming = false @State private var renameDraft = "" @@ -81,7 +79,7 @@ struct PuzzleView: View { .padding(6) .glassEffect( session.isPencilMode - ? .regular.tint(playerColor.tint) + ? .regular.tint(preferences.color.tint) : .identity, in: Circle() ) @@ -114,9 +112,9 @@ struct PuzzleView: View { Section { Button {} label: { Label { - Text(playerName) + Text(preferences.name) } icon: { - swatchImage(for: PlayerColor.color(for: playerColorID)) + swatchImage(for: preferences.color) } } .disabled(true) @@ -126,10 +124,10 @@ struct PuzzleView: View { Menu("Change Colour") { ForEach(PlayerColor.palette) { color in Button { - playerColorID = color.id + preferences.color = color } label: { Label { - Text(color.id == playerColorID ? "\(color.name) ✓" : color.name) + Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) } icon: { swatchImage(for: color) } @@ -138,7 +136,7 @@ struct PuzzleView: View { } Button("Change Name") { - renameDraft = playerName + renameDraft = preferences.name isRenaming = true } } @@ -164,7 +162,7 @@ struct PuzzleView: View { Button("Save") { let trimmed = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { - playerName = trimmed + preferences.name = trimmed } } } message: { @@ -180,9 +178,11 @@ private struct ClueKey: Hashable { private struct ClueBar: View { @Bindable var session: PlayerSession - @Environment(\.playerColor) private var playerColor + @Environment(PlayerPreferences.self) private var preferences @State private var previousKey: ClueKey? + private var playerColor: PlayerColor { preferences.color } + var body: some View { let clue = session.currentClue() let currentKey = clue.map { ClueKey(direction: session.direction, number: $0.number) } diff --git a/Crossmate/Views/SyncDiagnosticsView.swift b/Crossmate/Views/SyncDiagnosticsView.swift @@ -121,13 +121,13 @@ struct SyncDiagnosticsView: View { isSyncing = true defer { isSyncing = false } - await RootView.run("manual bootstrap", monitor: syncMonitor) { + await AppServices.run("manual bootstrap", monitor: syncMonitor) { try await syncEngine.bootstrap() } - await RootView.run("manual fetch", monitor: syncMonitor) { + await AppServices.run("manual fetch", monitor: syncMonitor) { try await syncEngine.fetchChanges() } - await RootView.run("manual push", monitor: syncMonitor) { + await AppServices.run("manual push", monitor: syncMonitor) { try await syncEngine.pushChanges() } let snapshot = await syncEngine.diagnosticSnapshot()