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:
Rocky
2026-07-08 18:13:56 +08:00
parent 128aab1b02
commit c2f07bd8d2
99 changed files with 6735 additions and 740 deletions
@@ -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
)
}
}