c2f07bd8d2
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.
208 lines
7.8 KiB
Swift
208 lines
7.8 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
|
|
}
|
|
|
|
@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
|
|
|
|
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 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()
|
|
ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs)
|
|
ICloudSyncPreferences.cacheToAppGroup(
|
|
settingsEnabled: true,
|
|
dictionaryEnabled: store.personalDictionaryICloudSyncEnabled,
|
|
store: store
|
|
)
|
|
|
|
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 = 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() {
|
|
let store = makeStore()
|
|
ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs)
|
|
store.setSettingsICloudSyncEnabled(false)
|
|
}
|
|
|
|
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
|
|
) {
|
|
var config = store.configurationSnapshot()
|
|
settings.applying(to: &config)
|
|
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 {
|
|
func configurationSnapshot() -> AppGroupConfiguration {
|
|
AppGroupConfiguration.load(fromAvailable: defaults)
|
|
}
|
|
|
|
func saveConfiguration(_ configuration: AppGroupConfiguration, settingsCloudUpdatedAt: Date) {
|
|
let config = configuration
|
|
config.save(to: defaults)
|
|
defaults.set(
|
|
settingsCloudUpdatedAt.timeIntervalSince1970,
|
|
forKey: AppGroupConfiguration.Keys.settingsCloudUpdatedAt
|
|
)
|
|
}
|
|
}
|