crossmate

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

commit e905838f71f1da7e48cfe954ec765b45f6b76af4
parent 12ed896d2fc23b65bb2dc3da4b17e84936684581
Author: Michael Camilleri <[email protected]>
Date:   Sat,  2 May 2026 10:06:51 +0900

Support toggling iCloud syncing

Diffstat:
MCrossmate/CrossmateApp.swift | 2+-
MCrossmate/Models/PlayerPreferences.swift | 9+++++++++
MCrossmate/Services/AppServices.swift | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
MCrossmate/Views/SettingsView.swift | 4++++
4 files changed, 78 insertions(+), 17 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -315,7 +315,7 @@ private struct PuzzleDisplayView: View { let (game, mutator) = try store.loadGame(id: gameID) let newSession = PlayerSession(game: game, mutator: mutator) newSession.performanceMonitor = services.performanceMonitor - if mutator.isShared { + if mutator.isShared && preferences.isICloudSyncEnabled { Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } roster = services.makePlayerRoster(for: gameID, preferences: preferences) // Fan out the local user's name to every shared/joined diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift @@ -17,6 +17,7 @@ final class PlayerPreferences { private enum Keys { static let colorID = "playerColorID" static let name = "playerName" + static let isICloudSyncEnabled = "isICloudSyncEnabled" } private let local: UserDefaults @@ -30,6 +31,13 @@ final class PlayerPreferences { didSet { write(Keys.name, name) } } + /// Debugging switch for comparing on-device responsiveness with CloudKit + /// work paused. Stored locally only so disabling sync on one device does + /// not silently disable it everywhere. + var isICloudSyncEnabled: Bool { + didSet { local.set(isICloudSyncEnabled, forKey: Keys.isICloudSyncEnabled) } + } + var color: PlayerColor { get { PlayerColor.color(for: colorID) } set { colorID = newValue.id } @@ -47,6 +55,7 @@ final class PlayerPreferences { self.name = cloud.string(forKey: Keys.name) ?? local.string(forKey: Keys.name) ?? "Player" + self.isICloudSyncEnabled = local.object(forKey: Keys.isICloudSyncEnabled) as? Bool ?? true cloud.synchronize() NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -24,13 +24,15 @@ final class AppServices { private let ckContainer = CKContainer(identifier: "iCloud.net.inqk.crossmate.v2") private var started = false + private var syncStarted = false private(set) var nameBroadcaster: NameBroadcaster? private var isReadyForShareAcceptance = false private var isProcessingShareAcceptanceQueue = false private var pendingShareMetadatas: [CKShare.Metadata] = [] init() { - self.preferences = PlayerPreferences() + let preferences = PlayerPreferences() + self.preferences = preferences self.persistence = PersistenceController() let store = GameStore(persistence: persistence) self.store = store @@ -54,6 +56,8 @@ final class AppServices { debounceInterval: .milliseconds(1500), persistence: persistence, sink: { moves in + let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } + guard isEnabled else { return } await syncEngine.enqueueMoves(moves) }, performanceSink: { [performanceMonitor] name, durationMS, detail, thresholdMS in @@ -65,6 +69,9 @@ final class AppServices { ) }, afterFlush: { [performanceMonitor] gameIDs in + let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } + guard isEnabled else { return } + let snapshotStart = ContinuousClock.now let result = await store.createSnapshotsIfNeeded(for: gameIDs) await performanceMonitor.record( @@ -95,6 +102,7 @@ final class AppServices { ) }, sessionPingSink: { [preferences] gameID, authorID in + guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } let name = await MainActor.run { preferences.name } await syncEngine.enqueueSessionPing( gameID: gameID, @@ -108,21 +116,33 @@ final class AppServices { self.presencePublisher = PresencePublisher( persistence: persistence, sink: { gameID, authorID in + let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } + guard isEnabled else { return } await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) } ) store.authorIDProvider = { identity.currentID } - store.onGameCreated = { [syncEngine] ckRecordName in - Task { await syncEngine.enqueueGame(ckRecordName: ckRecordName) } + store.onGameCreated = { [preferences, syncEngine] ckRecordName in + Task { + guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } + await syncEngine.enqueueGame(ckRecordName: ckRecordName) + } } - store.onGameUpdated = { [syncEngine] ckRecordName in - Task { await syncEngine.enqueueGame(ckRecordName: ckRecordName) } + store.onGameUpdated = { [preferences, syncEngine] ckRecordName in + Task { + guard await MainActor.run(body: { preferences.isICloudSyncEnabled }) else { return } + await syncEngine.enqueueGame(ckRecordName: ckRecordName) + } } let colorStore = GamePlayerColorStore() - store.onGameDeleted = Self.makeOnGameDeleted( + let onGameDeleted = Self.makeOnGameDeleted( syncEngine: syncEngine, colorStore: colorStore ) + store.onGameDeleted = { [preferences] ckRecordNames in + guard preferences.isICloudSyncEnabled else { return } + onGameDeleted(ckRecordNames) + } self.colorStore = colorStore self.cloudService = CloudService( container: self.ckContainer, @@ -180,9 +200,6 @@ final class AppServices { await syncEngine.enqueueDeleteRecords(prunedMoveNames) } - // Fetch identity before starting engines so first moves get an authorID. - await identity.refresh(using: ckContainer) - // NameBroadcaster fans out name changes to all shared/joined games. // PuzzleDisplayView also calls `broadcastName()` when a shared puzzle // is opened, which covers first-sync-after-share-create / accept. @@ -190,18 +207,17 @@ final class AppServices { preferences: preferences, persistence: persistence, authorIdentity: identity, - enqueuePlayerRecord: { [syncEngine] gameID, authorID in + enqueuePlayerRecord: { [preferences, syncEngine] gameID, authorID in + let isEnabled = await MainActor.run { preferences.isICloudSyncEnabled } + guard isEnabled else { return } await syncEngine.enqueuePlayerRecord(gameID: gameID, authorID: authorID) } ) - await syncEngine.start() - let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() - if recoveredMoveCount > 0 { - syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue") + guard await ensureICloudSyncStarted() else { + syncMonitor.note("iCloud sync disabled — initial fetch/push skipped") + return } - isReadyForShareAcceptance = true - await processPendingShareAcceptances() await syncMonitor.run("initial fetch") { try await syncEngine.fetchChanges(source: "initial") @@ -213,6 +229,10 @@ final class AppServices { } func enqueueShareAcceptance(_ metadata: CKShare.Metadata) async { + guard preferences.isICloudSyncEnabled else { + syncMonitor.note("share acceptance ignored while iCloud sync is disabled") + return + } pendingShareMetadatas.append(metadata) syncMonitor.note( "share acceptance queued: container=\(metadata.containerIdentifier)" @@ -222,6 +242,11 @@ final class AppServices { func syncOnForeground() async { await moveBuffer.flush() + guard await ensureICloudSyncStarted() else { return } + let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() + if recoveredMoveCount > 0 { + syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue") + } await syncMonitor.run("foreground fetch") { try await syncEngine.fetchChanges(source: "foreground") } @@ -237,6 +262,7 @@ final class AppServices { func syncOpenSharedPuzzle() async { await moveBuffer.flush() + guard await ensureICloudSyncStarted() else { return } await syncMonitor.run("open-puzzle fetch") { try await syncEngine.fetchChanges(source: "open-puzzle poll") } @@ -256,12 +282,34 @@ final class AppServices { } private func handleRemoteNotification(summary: String) async { + guard preferences.isICloudSyncEnabled else { + syncMonitor.note("remote notification ignored while iCloud sync is disabled") + return + } + guard await ensureICloudSyncStarted() else { return } syncMonitor.note("remote notification: \(summary)") await syncMonitor.run("remote-notification fetch") { try await syncEngine.fetchChanges(source: "push") } } + private func ensureICloudSyncStarted() async -> Bool { + guard preferences.isICloudSyncEnabled else { return false } + guard !syncStarted else { return true } + + await identity.refresh(using: ckContainer) + await syncEngine.start() + syncStarted = true + + let recoveredMoveCount = await syncEngine.enqueueUnconfirmedMoves() + if recoveredMoveCount > 0 { + syncMonitor.note("recovered \(recoveredMoveCount) unconfirmed move(s) for CloudKit enqueue") + } + isReadyForShareAcceptance = true + await processPendingShareAcceptances() + return true + } + private func presentSessionPings(_ pings: [SessionPing]) async { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() diff --git a/Crossmate/Views/SettingsView.swift b/Crossmate/Views/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI struct SettingsView: View { @Environment(NYTAuthService.self) private var nytAuth + @Environment(PlayerPreferences.self) private var preferences @Environment(\.dismiss) private var dismiss @Environment(\.resetDatabase) private var resetDatabase @@ -9,6 +10,7 @@ struct SettingsView: View { @State private var showResetConfirmation = false var body: some View { + @Bindable var preferences = preferences NavigationStack { Form { Section("NYT Account") { @@ -20,6 +22,8 @@ struct SettingsView: View { } Section("Debugging") { + Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled) + NavigationLink("iCloud Diagnostics") { DiagnosticsView() }