feat(macos): add macOS menu-bar app and harden cross-device iCloud sync
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Single entry point for iCloud KVS sync in the main app: preferences
|
||||
// toggles, settings payload, and personal dictionary.
|
||||
// toggles, usage statistics, settings payload, speech history, and
|
||||
// personal dictionary.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -14,18 +15,28 @@ public final class AppCloudSync {
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let settingsSync: SettingsCloudSync
|
||||
private let dictionarySync: PersonalDictionaryCloudSync
|
||||
private let usageStatisticsSync: UsageStatisticsCloudSync
|
||||
private let speechHistorySync: SpeechHistoryCloudSync
|
||||
private var externalChangeObserver: NSObjectProtocol?
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard },
|
||||
settingsSync: SettingsCloudSync? = nil,
|
||||
dictionarySync: PersonalDictionaryCloudSync? = nil
|
||||
dictionarySync: PersonalDictionaryCloudSync? = nil,
|
||||
usageStatisticsSync: UsageStatisticsCloudSync? = nil,
|
||||
speechHistorySync: SpeechHistoryCloudSync? = nil
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.settingsSync = settingsSync ?? SettingsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.settingsSync = settingsSync
|
||||
?? SettingsCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
self.dictionarySync = dictionarySync ?? PersonalDictionaryCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.usageStatisticsSync = usageStatisticsSync
|
||||
?? UsageStatisticsCloudSync(kvs: kvs, makeStore: makeStore)
|
||||
self.speechHistorySync = speechHistorySync
|
||||
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
}
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
@@ -62,9 +73,28 @@ public final class AppCloudSync {
|
||||
)
|
||||
|
||||
await settingsSync.pullAndMergeIfEnabled()
|
||||
await usageStatisticsSync.pullAndMergeIfEnabled()
|
||||
await speechHistorySync.pullAndMergeIfEnabled()
|
||||
await dictionarySync.pullAndMergeIfEnabled()
|
||||
}
|
||||
|
||||
/// Low-risk manual sync: pull remote changes, merge, then push local state.
|
||||
public func syncNow() async throws {
|
||||
let store = makeStore()
|
||||
await pullAllIfEnabled()
|
||||
|
||||
if store.settingsICloudSyncEnabled {
|
||||
try await settingsSync.pushLocalIfEnabled()
|
||||
try await usageStatisticsSync.pushLocalIfEnabled()
|
||||
try await speechHistorySync.pushLocalIfEnabled()
|
||||
}
|
||||
if store.personalDictionaryICloudSyncEnabled {
|
||||
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
public var settingsSyncService: SettingsCloudSync { settingsSync }
|
||||
public var dictionarySyncService: PersonalDictionaryCloudSync { dictionarySync }
|
||||
public var usageStatisticsSyncService: UsageStatisticsCloudSync { usageStatisticsSync }
|
||||
public var speechHistorySyncService: SpeechHistoryCloudSync { speechHistorySync }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// CloudSyncContext.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Injectable AppCloudSync instance so iOS and Mac share one sync graph.
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public enum CloudSyncContext {
|
||||
private static var configured: AppCloudSync?
|
||||
|
||||
public static var shared: AppCloudSync {
|
||||
configured ?? AppCloudSync.shared
|
||||
}
|
||||
|
||||
public static func configure(_ sync: AppCloudSync) {
|
||||
configured = sync
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// SettingsCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors user-facing app settings through iCloud KVS. API keys stay
|
||||
// in Keychain and are never uploaded.
|
||||
// Mirrors user-facing app settings through iCloud KVS (`appSettings.v2`)
|
||||
// with per-field merge. API keys sync via iCloud Keychain — never KVS.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -22,17 +22,21 @@ public enum SettingsCloudSyncError: Error, Equatable, Sendable {
|
||||
public final class SettingsCloudSync {
|
||||
public static let shared = SettingsCloudSync()
|
||||
|
||||
public static let kvsKey = "appSettings.v1"
|
||||
public static let kvsKey = SyncedAppSettingsV2.kvsKey
|
||||
public static let legacyKVSKey = SyncedAppSettings.legacyKVSKey
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let historyDefaults: () -> UserDefaults
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.historyDefaults = historyDefaults
|
||||
}
|
||||
|
||||
public func pullAndMergeIfEnabled() async {
|
||||
@@ -44,8 +48,15 @@ public final class SettingsCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
try push(local)
|
||||
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 {
|
||||
@@ -57,12 +68,26 @@ public final class SettingsCloudSync {
|
||||
store: store
|
||||
)
|
||||
|
||||
let local = SyncedAppSettings.from(configuration: store.configurationSnapshot())
|
||||
Keychain.migrateLocalKeysToICloud()
|
||||
|
||||
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 = SyncedAppSettings.merge(local: local, remote: remote)
|
||||
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() {
|
||||
@@ -75,17 +100,19 @@ public final class SettingsCloudSync {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let local = SyncedAppSettings.from(
|
||||
configuration: store.configurationSnapshot(),
|
||||
updatedAt: store.settingsCloudUpdatedAt ?? .distantPast
|
||||
)
|
||||
let merged = SyncedAppSettings.merge(local: local, remote: remote)
|
||||
guard merged != local 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: SyncedAppSettings) throws {
|
||||
public func push(_ settings: SyncedAppSettingsV2) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(settings) else {
|
||||
@@ -95,12 +122,28 @@ public final class SettingsCloudSync {
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedAppSettings? {
|
||||
guard let data = kvs.data(forKey: Self.kvsKey) else { return nil }
|
||||
return try? decode(data)
|
||||
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 decode(_ data: Data) throws -> SyncedAppSettings {
|
||||
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 {
|
||||
@@ -110,18 +153,42 @@ public final class SettingsCloudSync {
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ settings: SyncedAppSettings,
|
||||
_ settings: SyncedAppSettingsV2,
|
||||
to store: AppGroupStore,
|
||||
postNotification: Bool
|
||||
) {
|
||||
var config = store.configurationSnapshot()
|
||||
settings.applying(to: &config)
|
||||
store.saveConfiguration(config, settingsCloudUpdatedAt: settings.updatedAt)
|
||||
store.saveConfiguration(config, 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 {
|
||||
@@ -132,6 +199,9 @@ private extension AppGroupStore {
|
||||
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
|
||||
let config = configuration
|
||||
config.save(to: defaults)
|
||||
defaults.set(settingsCloudUpdatedAt.timeIntervalSince1970, forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt)
|
||||
defaults.set(
|
||||
settingsCloudUpdatedAt.timeIntervalSince1970,
|
||||
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SpeechHistoryCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors speech history through iCloud KVS when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after remote speech history is applied locally.
|
||||
static let speechHistoryDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.speechHistory.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum SpeechHistoryCloudSyncError: Error, Equatable, Sendable {
|
||||
case payloadTooLarge(byteCount: Int)
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class SpeechHistoryCloudSync {
|
||||
public static let shared = SpeechHistoryCloudSync()
|
||||
|
||||
public static let kvsKey = SyncedSpeechHistory.kvsKey
|
||||
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
private let historyDefaults: () -> UserDefaults
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() },
|
||||
historyDefaults: @escaping () -> UserDefaults = { .standard }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
self.historyDefaults = historyDefaults
|
||||
}
|
||||
|
||||
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 local = SpeechHistoryStorage.load(from: historyDefaults())
|
||||
try push(local)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote history.
|
||||
public func mergeAndPushIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
||||
apply(merged, to: defaults, postNotification: false)
|
||||
try push(merged)
|
||||
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let merged = SyncedSpeechHistory.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
apply(merged, to: defaults, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ history: SyncedSpeechHistory) throws {
|
||||
let data = try encode(history)
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedSpeechHistory? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decode(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
|
||||
return try? decode(legacyData)
|
||||
}
|
||||
|
||||
public func encode(_ history: SyncedSpeechHistory) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(history) else {
|
||||
throw SpeechHistoryCloudSyncError.encodeFailed
|
||||
}
|
||||
guard data.count <= Self.maxPayloadBytes else {
|
||||
throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: data.count)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func decode(_ data: Data) throws -> SyncedSpeechHistory {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let history = try? decoder.decode(SyncedSpeechHistory.self, from: data) else {
|
||||
throw SpeechHistoryCloudSyncError.decodeFailed
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ history: SyncedSpeechHistory,
|
||||
to defaults: UserDefaults,
|
||||
postNotification: Bool
|
||||
) {
|
||||
SpeechHistoryStorage.save(history, to: defaults)
|
||||
if postNotification {
|
||||
NotificationCenter.default.post(name: .speechHistoryDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SyncDeviceID.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Stable per-install identifier for per-field / per-device iCloud merge.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum SyncDeviceID {
|
||||
private static let defaultsKey = "sync.deviceID.v1"
|
||||
|
||||
/// Returns a stable device id stored in the active defaults suite.
|
||||
public static func current(defaults: UserDefaults = AppGroupStore().defaults) -> String {
|
||||
if let existing = defaults.string(forKey: defaultsKey), !existing.isEmpty {
|
||||
return existing
|
||||
}
|
||||
let created = UUID().uuidString
|
||||
defaults.set(created, forKey: defaultsKey)
|
||||
return created
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// UsageStatisticsCloudSync.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Mirrors cumulative usage statistics through iCloud KVS (`usageStatistics.v2`)
|
||||
// using per-device G-Counter merge when settings sync is enabled.
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name {
|
||||
/// Posted after remote usage statistics are applied locally.
|
||||
static let usageStatisticsDidSyncFromCloud = Notification.Name(
|
||||
"com.osgkeyboard.usageStatistics.didSyncFromCloud"
|
||||
)
|
||||
}
|
||||
|
||||
public enum UsageStatisticsCloudSyncError: Error, Equatable, Sendable {
|
||||
case encodeFailed
|
||||
case decodeFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class UsageStatisticsCloudSync {
|
||||
public static let shared = UsageStatisticsCloudSync()
|
||||
|
||||
public static let kvsKey = SyncedUsageStatisticsV2.kvsKey
|
||||
public static let legacyKVSKey = SyncedUsageStatisticsStorage.legacyStorageKey
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
|
||||
public init(
|
||||
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
|
||||
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
|
||||
) {
|
||||
self.kvs = kvs
|
||||
self.makeStore = makeStore
|
||||
}
|
||||
|
||||
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 local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
try push(local)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote totals.
|
||||
public func mergeAndPushIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
let remote = loadRemote() ?? local
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
|
||||
apply(merged, to: store.defaults, postNotification: false)
|
||||
try push(merged)
|
||||
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
|
||||
public func pullAndMerge(store: AppGroupStore) async {
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
guard let remote = loadRemote() else { return }
|
||||
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
let merged = SyncedUsageStatisticsV2.merge(local: local, remote: remote)
|
||||
guard merged != local else { return }
|
||||
|
||||
apply(merged, to: store.defaults, postNotification: true)
|
||||
}
|
||||
|
||||
public func push(_ stats: SyncedUsageStatisticsV2) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
guard let data = try? encoder.encode(stats) else {
|
||||
throw UsageStatisticsCloudSyncError.encodeFailed
|
||||
}
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
/// Removes the cumulative-stats payload from iCloud KVS. Used by the
|
||||
/// one-time cleanup that clears data corrupted by the pre-fix
|
||||
/// double-counting bug so it can't be pulled back onto other devices.
|
||||
public func purgeRemote() {
|
||||
kvs.set(Data?.none, forKey: Self.kvsKey)
|
||||
kvs.set(Data?.none, forKey: Self.legacyKVSKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedUsageStatisticsV2? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decodeV2(data)
|
||||
}
|
||||
guard let legacyData = kvs.data(forKey: Self.legacyKVSKey) else { return nil }
|
||||
let deviceID = SyncDeviceID.current()
|
||||
guard let legacy = try? decodeLegacy(legacyData) else { return nil }
|
||||
return SyncedUsageStatisticsV2.migrated(from: legacy, deviceID: deviceID)
|
||||
}
|
||||
|
||||
public func decodeV2(_ data: Data) throws -> SyncedUsageStatisticsV2 {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let stats = try? decoder.decode(SyncedUsageStatisticsV2.self, from: data) else {
|
||||
throw UsageStatisticsCloudSyncError.decodeFailed
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
public func decodeLegacy(_ data: Data) throws -> UsageStatistics {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
guard let stats = try? decoder.decode(UsageStatistics.self, from: data) else {
|
||||
throw UsageStatisticsCloudSyncError.decodeFailed
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
private func apply(
|
||||
_ stats: SyncedUsageStatisticsV2,
|
||||
to defaults: UserDefaults,
|
||||
postNotification: Bool
|
||||
) {
|
||||
SyncedUsageStatisticsStorage.save(stats, to: defaults)
|
||||
if postNotification {
|
||||
NotificationCenter.default.post(name: .usageStatisticsDidSyncFromCloud, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user