Bedrock/Sources/Bedrock/Storage/CloudSyncManager.swift
Matt Bruce fa7d848f52 Initial commit: Bedrock design system and UI component library
- Design system: spacing, typography, colors, animations, opacity, shadows
- Protocol-based color theming (AppColorTheme)
- Settings UI components: toggles, pickers, selection indicators
- Sound and haptic feedback manager (generic AppSound protocol)
- Onboarding state management
- Cloud sync manager (PersistableData protocol)
- Visual effects: ConfettiView, PulsingModifier
- Debug utilities: DebugBorderModifier
- Device information utilities (cross-platform)
- Unit tests for design constants
2026-01-02 11:58:30 -06:00

416 lines
13 KiB
Swift

//
// CloudSyncManager.swift
// Bedrock
//
// Generic iCloud sync manager for app data.
// Uses NSUbiquitousKeyValueStore for lightweight cross-device sync.
//
import Foundation
import SwiftUI
// MARK: - PersistableData Protocol
/// Protocol for data that can be persisted locally and synced to iCloud.
///
/// Conform to this protocol to enable automatic iCloud sync for your app's data.
///
/// ## Example
///
/// ```swift
/// struct GameStats: PersistableData {
/// static var dataIdentifier = "gameStats"
/// static var empty = GameStats()
///
/// var gamesPlayed: Int = 0
/// var lastModified: Date = .now
///
/// var syncPriority: Int { gamesPlayed } // Higher = more progress
/// }
/// ```
public protocol PersistableData: Codable, Sendable {
/// Unique identifier for this data type (e.g., "userProfile", "gameStats").
/// Used as the storage key.
static var dataIdentifier: String { get }
/// Priority value for conflict resolution.
/// When cloud and local data conflict, the version with higher priority wins.
/// Typically represents "progress" (e.g., games played, items collected).
var syncPriority: Int { get }
/// Last time this data was modified.
var lastModified: Date { get set }
/// Creates empty/default data.
static var empty: Self { get }
}
// MARK: - CloudSyncManager
/// Manages data persistence to local storage and iCloud.
///
/// Features:
/// - Automatic sync with iCloud using `NSUbiquitousKeyValueStore`
/// - Local fallback using `UserDefaults`
/// - Conflict resolution based on `syncPriority`
/// - Change notifications when data updates from another device
///
/// ## Usage
///
/// ```swift
/// @Observable
/// class AppState {
/// let syncManager = CloudSyncManager<UserData>()
///
/// var userData: UserData {
/// syncManager.data
/// }
///
/// func saveProgress() {
/// syncManager.update { data in
/// data.level += 1
/// }
/// }
/// }
/// ```
@MainActor
@Observable
public final class CloudSyncManager<T: PersistableData> {
// MARK: - Properties
/// The current data.
public private(set) var data: T
/// Whether iCloud sync is available (user signed in to iCloud).
public var iCloudAvailable: Bool {
let token = FileManager.default.ubiquityIdentityToken
let available = token != nil
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud available = \(available)")
return available
}
/// Whether iCloud sync is enabled by the user.
public var iCloudEnabled: Bool {
get { UserDefaults.standard.bool(forKey: iCloudEnabledKey) }
set {
UserDefaults.standard.set(newValue, forKey: iCloudEnabledKey)
if newValue { sync() }
}
}
/// Last successful sync date.
public private(set) var lastSyncDate: Date?
/// Whether a sync is currently in progress.
public private(set) var isSyncing: Bool = false
/// Human-readable sync status.
public private(set) var syncStatus: String = ""
/// Whether initial iCloud sync has completed.
public private(set) var hasCompletedInitialSync: Bool = false
/// Callback when data is received from iCloud.
public var onCloudDataReceived: ((T) -> Void)?
// MARK: - Private Properties
private var _iCloudStore: NSUbiquitousKeyValueStore?
private var _iCloudStoreInitialized = false
private var iCloudStore: NSUbiquitousKeyValueStore? {
if _iCloudStoreInitialized {
return _iCloudStore
}
_iCloudStoreInitialized = true
guard iCloudAvailable else {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud not available")
return nil
}
_iCloudStore = NSUbiquitousKeyValueStore.default
return _iCloudStore
}
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var localKey: String { "\(T.dataIdentifier).data" }
private var cloudKey: String { "\(T.dataIdentifier).data" }
private var syncDateKey: String { "\(T.dataIdentifier).lastSync" }
private var iCloudEnabledKey: String { "\(T.dataIdentifier).iCloudEnabled" }
// MARK: - Initialization
public init() {
self.data = T.empty
// Default iCloud enabled to true
if UserDefaults.standard.object(forKey: iCloudEnabledKey) == nil {
UserDefaults.standard.set(true, forKey: iCloudEnabledKey)
}
// Register for iCloud changes
if iCloudAvailable, let store = iCloudStore {
NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: store,
queue: .main
) { [weak self] notification in
guard let userInfo = notification.userInfo,
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int,
let changedKeys = userInfo[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String] else {
return
}
Task { @MainActor in
self?.handleCloudChange(reason: reason, changedKeys: changedKeys)
}
}
// Trigger iCloud sync
if iCloudEnabled {
store.synchronize()
}
}
// Load data
self.data = load()
// On fresh install, wait for iCloud data
if data.syncPriority == 0 && iCloudAvailable && iCloudEnabled {
scheduleDelayedCloudCheck()
}
}
// MARK: - Save
/// Saves the current data locally and to iCloud.
public func save(_ newData: T) {
var dataToSave = newData
dataToSave.lastModified = Date()
self.data = dataToSave
guard let encoded = try? encoder.encode(dataToSave) else {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Failed to encode data")
return
}
// Save locally
UserDefaults.standard.set(encoded, forKey: localKey)
// Save to iCloud
if iCloudAvailable && iCloudEnabled, let store = iCloudStore {
store.set(encoded, forKey: cloudKey)
store.set(Date(), forKey: syncDateKey)
store.synchronize()
lastSyncDate = Date()
syncStatus = "Synced"
}
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Saved (priority: \(dataToSave.syncPriority))")
}
/// Updates and saves data in one call.
public func update(_ transform: (inout T) -> Void) {
var updated = data
transform(&updated)
save(updated)
}
// MARK: - Load
/// Loads data, preferring iCloud if it has higher priority.
public func load() -> T {
let localData = loadLocal()
let cloudData = loadCloud()
let finalData: T
switch (localData, cloudData) {
case (nil, nil):
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: No saved data, using empty")
finalData = T.empty
case (let local?, nil):
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local data")
finalData = local
case (nil, let cloud?):
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud data")
finalData = cloud
case (let local?, let cloud?):
// Use whichever has higher priority (more progress)
if cloud.syncPriority > local.syncPriority {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using iCloud (priority \(cloud.syncPriority) > \(local.syncPriority))")
finalData = cloud
// Update local with cloud data
if let encoded = try? encoder.encode(cloud) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
} else if local.lastModified > cloud.lastModified {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Using local (newer)")
finalData = local
} else {
finalData = local
}
}
return finalData
}
private func loadLocal() -> T? {
guard let data = UserDefaults.standard.data(forKey: localKey),
let decoded = try? decoder.decode(T.self, from: data) else {
return nil
}
return decoded
}
private func loadCloud() -> T? {
guard iCloudAvailable && iCloudEnabled,
let store = iCloudStore,
let data = store.data(forKey: cloudKey),
let decoded = try? decoder.decode(T.self, from: data) else {
return nil
}
if let syncDate = store.object(forKey: syncDateKey) as? Date {
lastSyncDate = syncDate
}
return decoded
}
// MARK: - Sync
/// Forces a sync with iCloud.
public func sync() {
guard iCloudAvailable && iCloudEnabled else {
syncStatus = iCloudAvailable ? "Sync disabled" : "iCloud unavailable"
return
}
guard let store = iCloudStore else {
syncStatus = "iCloud unavailable"
return
}
isSyncing = true
syncStatus = "Syncing..."
store.synchronize()
// Reload to get any changes
let latestData = load()
if latestData.syncPriority != data.syncPriority {
data = latestData
onCloudDataReceived?(latestData)
}
isSyncing = false
lastSyncDate = Date()
syncStatus = "Synced"
}
// MARK: - Reset
/// Clears all saved data locally and from iCloud.
public func reset() {
UserDefaults.standard.removeObject(forKey: localKey)
if iCloudAvailable, let store = iCloudStore {
store.removeObject(forKey: cloudKey)
store.removeObject(forKey: syncDateKey)
store.synchronize()
}
data = T.empty
syncStatus = "Data cleared"
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: All data cleared")
}
// MARK: - Private Methods
private func scheduleDelayedCloudCheck() {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Scheduling delayed cloud check...")
Task { @MainActor in
try? await Task.sleep(for: .seconds(2))
guard let store = iCloudStore else {
hasCompletedInitialSync = true
return
}
_ = store.synchronize()
if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority {
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Cloud has more data, updating...")
data = cloudData
onCloudDataReceived?(cloudData)
if let encoded = try? encoder.encode(cloudData) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
NotificationCenter.default.post(
name: .persistedDataDidChange,
object: nil,
userInfo: ["dataIdentifier": T.dataIdentifier]
)
}
hasCompletedInitialSync = true
}
}
private func handleCloudChange(reason: Int, changedKeys: [String]) {
guard changedKeys.contains(cloudKey) else { return }
switch reason {
case NSUbiquitousKeyValueStoreServerChange,
NSUbiquitousKeyValueStoreInitialSyncChange:
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: Data changed from another device")
syncStatus = "Received update"
if let cloudData = loadCloud(), cloudData.syncPriority > data.syncPriority {
data = cloudData
onCloudDataReceived?(cloudData)
if let encoded = try? encoder.encode(cloudData) {
UserDefaults.standard.set(encoded, forKey: localKey)
}
NotificationCenter.default.post(
name: .persistedDataDidChange,
object: nil,
userInfo: ["dataIdentifier": T.dataIdentifier]
)
}
case NSUbiquitousKeyValueStoreQuotaViolationChange:
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud quota exceeded")
syncStatus = "Storage full"
case NSUbiquitousKeyValueStoreAccountChange:
Design.debugLog("CloudSyncManager[\(T.dataIdentifier)]: iCloud account changed")
syncStatus = "Account changed"
data = load()
default:
break
}
}
}
// MARK: - Notifications
public extension Notification.Name {
/// Posted when persisted data changes from iCloud.
static let persistedDataDidChange = Notification.Name("persistedDataDidChange")
}