498f407585
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.
240 lines
9.1 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|