Files
OSGKeyboard/OSGKeyboardShared/Services/PersonalDictionaryCloudSync/PersonalDictionaryCloudSync.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

158 lines
5.4 KiB
Swift

// PersonalDictionaryCloudSync.swift
// OSGKeyboard · Shared
//
// Mirrors the personal dictionary through iCloud Key-Value Store while
// keeping App Group UserDefaults as the keyboard extension's runtime
// source of truth. Intended for main-app call sites only.
import Foundation
public extension Notification.Name {
/// Posted after a remote KVS pull updates the App Group dictionary.
static let personalDictionaryDidSyncFromCloud = Notification.Name(
"com.osgkeyboard.personalDictionary.didSyncFromCloud"
)
}
public enum PersonalDictionaryCloudSyncError: Error, Equatable, Sendable {
case payloadTooLarge(byteCount: Int)
case encodeFailed
case decodeFailed
}
@MainActor
public final class PersonalDictionaryCloudSync {
public static let shared = PersonalDictionaryCloudSync()
public static let kvsKey = PersonalDictionary.kvsKeyV2
public static let legacyKVSKey = PersonalDictionary.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 var externalChangeObserver: NSObjectProtocol?
public init(
kvs: UbiquitousKeyValueStoreing = NSUbiquitousKeyValueStore.default,
makeStore: @escaping () -> AppGroupStore = { AppGroupStore() }
) {
self.kvs = kvs
self.makeStore = makeStore
}
// MARK: - Lifecycle
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.pullAndMergeIfEnabled()
}
}
}
public func stopObservingExternalChanges() {
if let externalChangeObserver {
NotificationCenter.default.removeObserver(externalChangeObserver)
self.externalChangeObserver = nil
}
}
/// Pull remote changes on launch / foreground when sync is enabled.
public func pullAndMergeIfEnabled() async {
let store = makeStore()
guard store.personalDictionaryICloudSyncEnabled else { return }
await pullAndMerge(store: store)
}
/// Push the current local dictionary when sync is enabled.
public func pushLocalIfEnabled(_ dictionary: PersonalDictionary) async throws {
let store = makeStore()
guard store.personalDictionaryICloudSyncEnabled else { return }
try push(dictionary)
}
/// Enable sync: merge local + remote, persist locally, then upload.
public func enableSync() async throws {
let store = makeStore()
ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs)
ICloudSyncPreferences.cacheToAppGroup(
settingsEnabled: store.settingsICloudSyncEnabled,
dictionaryEnabled: true,
store: store
)
let local = store.personalDictionary
let remote = loadRemote() ?? .empty
let merged = PersonalDictionary.merge(local: local, remote: remote)
store.setPersonalDictionary(merged)
try push(merged)
}
public func disableSync() {
let store = makeStore()
ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs)
store.setPersonalDictionaryICloudSyncEnabled(false)
}
// MARK: - Core operations
public func pullAndMerge(store: AppGroupStore) async {
guard store.personalDictionaryICloudSyncEnabled else { return }
let local = store.personalDictionary
guard let remote = loadRemote() else { return }
let merged = PersonalDictionary.merge(local: local, remote: remote)
guard merged != local else { return }
store.setPersonalDictionary(merged)
NotificationCenter.default.post(name: .personalDictionaryDidSyncFromCloud, object: nil)
}
public func push(_ dictionary: PersonalDictionary) throws {
var payload = dictionary
payload.lastSyncedAt = Date()
let data = try encode(payload)
kvs.set(data, forKey: Self.kvsKey)
_ = kvs.synchronize()
}
public func loadRemote() -> PersonalDictionary? {
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)
}
// MARK: - Encoding
public func encode(_ dictionary: PersonalDictionary) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
guard let data = try? encoder.encode(dictionary) else {
throw PersonalDictionaryCloudSyncError.encodeFailed
}
guard data.count <= Self.maxPayloadBytes else {
throw PersonalDictionaryCloudSyncError.payloadTooLarge(byteCount: data.count)
}
return data
}
public func decode(_ data: Data) throws -> PersonalDictionary {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let dictionary = try? decoder.decode(PersonalDictionary.self, from: data) else {
throw PersonalDictionaryCloudSyncError.decodeFailed
}
return dictionary
}
}