Files
OSGKeyboard/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift
T
Rocky 498f407585 feat(account): add managed credits and cloud gateway
Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
2026-08-20 11:43:21 +08:00

240 lines
9.1 KiB
Swift

// SettingsCloudSync.swift
// OSGKeyboard · Shared
//
// Mirrors user-facing app settings through iCloud KVS (`appSettings.v2`)
// with per-field merge. API keys sync via iCloud Keychain — never KVS.
import Foundation
public extension Notification.Name {
/// Posted after remote settings are applied to the App Group cache.
static let settingsDidSyncFromCloud = Notification.Name(
"com.osgkeyboard.settings.didSyncFromCloud"
)
}
public enum SettingsCloudSyncError: Error, Equatable, Sendable {
case encodeFailed
case decodeFailed
case credentialMigrationFailed(Keychain.CredentialMigrationError)
}
@MainActor
public final class SettingsCloudSync {
public static let shared = SettingsCloudSync()
public static let kvsKey = SyncedAppSettingsV2.kvsKey
public static let legacyKVSKey = SyncedAppSettings.legacyKVSKey
private let kvs: UbiquitousKeyValueStoreing
private let makeStore: () -> AppGroupStore
private let historyDefaults: () -> UserDefaults
private let migrateLocalKeysToICloud: () throws -> Void
private let migrateICloudKeysToLocal: () throws -> Void
public init(
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
historyDefaults: @escaping () -> UserDefaults = { .standard },
migrateLocalKeysToICloud: @escaping () throws -> Void = {
try Keychain.migrateLocalKeysToICloud()
},
migrateICloudKeysToLocal: @escaping () throws -> Void = {
try Keychain.migrateICloudKeysToLocal()
}
) {
self.kvs = kvs
self.makeStore = makeStore
self.historyDefaults = historyDefaults
self.migrateLocalKeysToICloud = migrateLocalKeysToICloud
self.migrateICloudKeysToLocal = migrateICloudKeysToLocal
}
public func pullAndMergeIfEnabled() async {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
await pullAndMerge(store: store)
}
public func pushLocalIfEnabled() async throws {
let store = makeStore()
guard store.settingsICloudSyncEnabled else { return }
let deviceID = SyncDeviceID.current(defaults: store.defaults)
let config = store.configurationSnapshot()
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
local = local.patchLocalChanges(from: config, deviceID: deviceID)
saveLocalPayload(local, to: store.defaults)
let remote = loadRemote()
let toPush = remote.map { SyncedAppSettingsV2.merge(local: local, remote: $0) } ?? local
try push(toPush)
}
public func enableSync() async throws {
let store = makeStore()
try performCredentialMigration(migrateLocalKeysToICloud)
ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs)
ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs)
ICloudSyncPreferences.cacheToAppGroup(
settingsEnabled: true,
dictionaryEnabled: true,
store: store
)
let deviceID = SyncDeviceID.current(defaults: store.defaults)
let config = store.configurationSnapshot()
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
local = local.patchLocalChanges(from: config, deviceID: deviceID)
let remote = loadRemote() ?? local
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
apply(merged, to: store, postNotification: false)
try push(merged)
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
let statisticsSync = UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
try await statisticsSync.mergeAndPushIfEnabled()
let historySync = SpeechHistoryCloudSync(
kvs: kvs,
makeStore: makeStore,
historyDefaults: historyDefaults
)
try await historySync.mergeAndPushIfEnabled()
}
public func disableSync() throws {
let store = makeStore()
try performCredentialMigration(migrateICloudKeysToLocal)
ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs)
ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs)
store.setSettingsICloudSyncEnabled(false)
store.setPersonalDictionaryICloudSyncEnabled(false)
}
private func performCredentialMigration(_ operation: () throws -> Void) throws {
do {
try operation()
} catch let error as Keychain.CredentialMigrationError {
throw SettingsCloudSyncError.credentialMigrationFailed(error)
} catch {
throw SettingsCloudSyncError.credentialMigrationFailed(.unavailable)
}
}
public func pullAndMerge(store: AppGroupStore) async {
guard store.settingsICloudSyncEnabled else { return }
guard let remote = loadRemote() else { return }
let deviceID = SyncDeviceID.current(defaults: store.defaults)
let config = store.configurationSnapshot()
var local = loadLocalPayload(from: store.defaults, configuration: config, deviceID: deviceID)
local = local.patchLocalChanges(from: config, deviceID: deviceID)
let merged = SyncedAppSettingsV2.merge(local: local, remote: remote)
var trial = config
merged.applying(to: &trial)
guard trial != config else { return }
apply(merged, to: store, postNotification: true)
}
public func push(_ settings: SyncedAppSettingsV2) throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(settings) else {
throw SettingsCloudSyncError.encodeFailed
}
kvs.set(data, forKey: Self.kvsKey)
_ = kvs.synchronize()
}
public func loadRemote() -> SyncedAppSettingsV2? {
if let data = kvs.data(forKey: Self.kvsKey) {
return try? decodeV2(data)
}
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey),
let legacy = try? decodeLegacy(legacyData) else {
return nil
}
let deviceID = SyncDeviceID.current()
return SyncedAppSettingsV2.migrated(from: legacy, deviceID: deviceID)
}
public func decodeV2(_ data: Data) throws -> SyncedAppSettingsV2 {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let settings = try? decoder.decode(SyncedAppSettingsV2.self, from: data) else {
throw SettingsCloudSyncError.decodeFailed
}
return settings
}
public func decodeLegacy(_ data: Data) throws -> SyncedAppSettings {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let settings = try? decoder.decode(SyncedAppSettings.self, from: data) else {
throw SettingsCloudSyncError.decodeFailed
}
return settings
}
private func apply(
_ settings: SyncedAppSettingsV2,
to store: AppGroupStore,
postNotification: Bool
) {
let baseline = store.configurationSnapshot()
var config = baseline
settings.applying(to: &config)
store.saveConfiguration(
config,
since: baseline,
settingsCloudUpdatedAt: settings.latestUpdatedAt
)
saveLocalPayload(settings, to: store.defaults)
if postNotification {
AppGroupConfigDarwin.postConfigChanged()
NotificationCenter.default.post(name: .settingsDidSyncFromCloud, object: nil)
}
}
private func loadLocalPayload(
from defaults: UserDefaults,
configuration: AppGroupConfiguration,
deviceID: String
) -> SyncedAppSettingsV2 {
if let data = defaults.data(forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2),
let payload = try? JSONDecoder().decode(SyncedAppSettingsV2.self, from: data) {
return payload
}
let stamp = defaults.object(forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt) as? TimeInterval
let updatedAt = stamp.map { Date(timeIntervalSince1970: $0) } ?? .distantPast
return SyncedAppSettingsV2.seeded(
from: configuration,
deviceID: deviceID,
updatedAt: updatedAt
)
}
private func saveLocalPayload(_ payload: SyncedAppSettingsV2, to defaults: UserDefaults) {
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: AppGroupConfiguration.Keys.settingsCloudPayloadV2)
}
}
private extension AppGroupStore {
func configurationSnapshot() -> AppGroupConfiguration {
AppGroupConfiguration.load(fromAvailable: defaults)
}
func saveConfiguration(
_ configuration: AppGroupConfiguration,
since baseline: AppGroupConfiguration,
settingsCloudUpdatedAt: Date
) {
configuration.saveChanges(since: baseline, to: defaults)
defaults.set(
settingsCloudUpdatedAt.timeIntervalSince1970,
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
)
}
}