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
@@ -36,6 +36,15 @@ public enum FlowSessionBridge {
}
}
/// Keyboard/read side: refresh App Group defaults after the extension was
/// suspended so decisions are not based on stale in-process caches.
public static func reloadFromDisk(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
if Thread.isMainThread {
store.synchronize()
}
}
// MARK: - Session lifecycle (host app)
public static func markSessionActive(
@@ -62,12 +71,17 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
setRecordingState(.idle, defaults: store)
clearTranscription(defaults: store)
clearHostReady(defaults: store, notify: false)
flush(store)
}
public static func writeHeartbeat(defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.flowHeartbeat)
let now = Date().timeIntervalSince1970
store.set(now, forKey: FlowSessionKeys.flowHeartbeat)
if store.bool(forKey: FlowSessionKeys.flowHostReady) {
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
}
flush(store)
}
@@ -118,8 +132,7 @@ public enum FlowSessionBridge {
// MARK: - Session validity (keyboard)
/// True when the App Group session contract is still valid (not expired).
/// Does **not** mean the host process is alive use `isHostReachable()` for
/// recording gates and "session ready" UI.
/// Does **not** mean the host can accept utterances use `isHostReady()`.
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
@@ -137,8 +150,8 @@ public enum FlowSessionBridge {
}
/// True when the host app recently wrote a heartbeat (foreground or
/// actively processing). Gating record / "session ready" UI must use this,
/// not `isSessionActive()` alone.
/// actively processing). Use for zombie / disconnect detection **not**
/// for mic-ready UI; prefer `isHostReady()`.
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
guard isSessionActive(defaults: store) else { return false }
@@ -146,6 +159,44 @@ public enum FlowSessionBridge {
return staleness <= FlowSessionKeys.heartbeatStaleInterval
}
// MARK: - Host ready contract (host app keyboard)
/// Host app: publish whether Flow can accept a new utterance right now.
public static func setHostReady(
_ ready: Bool,
defaults: UserDefaults? = nil,
notify: Bool = true
) {
let store = resolvedDefaults(defaults)
if ready {
let now = Date().timeIntervalSince1970
store.set(true, forKey: FlowSessionKeys.flowHostReady)
store.set(now, forKey: FlowSessionKeys.flowHostReadyAt)
writeHeartbeat(defaults: store)
} else {
clearHostReady(defaults: store, notify: false)
}
flush(store)
if notify {
FlowSessionDarwin.postHostReadyChanged()
}
}
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
public static func isHostReady(defaults: UserDefaults? = nil) -> Bool {
let store = resolvedDefaults(defaults)
guard isHostReachable(defaults: store) else { return false }
return store.bool(forKey: FlowSessionKeys.flowHostReady)
}
private static func clearHostReady(defaults: UserDefaults, notify: Bool) {
defaults.removeObject(forKey: FlowSessionKeys.flowHostReady)
defaults.removeObject(forKey: FlowSessionKeys.flowHostReadyAt)
if notify {
FlowSessionDarwin.postHostReadyChanged()
}
}
/// True when the session contract flag is still set but the host heartbeat
/// proves the process is gone (reboot, force-quit, long suspend).
public static func isHostStale(
@@ -346,6 +397,7 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.audioLevels)
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
clearHostReady(defaults: store, notify: false)
flush(store)
}