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:
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()