commit e905838f71f1da7e48cfe954ec765b45f6b76af4
parent 12ed896d2fc23b65bb2dc3da4b17e84936684581
Author: Michael Camilleri <[email protected]>
Date: Sat, 2 May 2026 10:06:51 +0900
Support toggling iCloud syncing
Diffstat:
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()
}