Files
OSGKeyboard/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift
T
Rocky c2f07bd8d2 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.
2026-07-08 18:13:56 +08:00

101 lines
4.0 KiB
Swift

// AppCloudSync.swift
// OSGKeyboard · Shared
//
// Single entry point for iCloud KVS sync in the main app: preferences
// toggles, usage statistics, settings payload, speech history, and
// personal dictionary.
import Foundation
@MainActor
public final class AppCloudSync {
public static let shared = AppCloudSync()
private let kvs: UbiquitousKeyValueStoreing
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,
usageStatisticsSync: UsageStatisticsCloudSync? = nil,
speechHistorySync: SpeechHistoryCloudSync? = nil
) {
self.kvs = kvs
self.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() {
guard externalChangeObserver == nil else { return }
externalChangeObserver = NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
await self.pullAllIfEnabled()
}
}
}
public func stopObservingExternalChanges() {
if let externalChangeObserver {
NotificationCenter.default.removeObserver(externalChangeObserver)
self.externalChangeObserver = nil
}
}
/// Launch / foreground: refresh KVS toggles, then pull payloads.
public func pullAllIfEnabled() async {
let store = makeStore()
ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store)
let toggles = ICloudSyncPreferences.load(from: kvs, store: store)
ICloudSyncPreferences.cacheToAppGroup(
settingsEnabled: toggles.settings,
dictionaryEnabled: toggles.dictionary,
store: store
)
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 }
}