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
@@ -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)
}
}
}