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
@@ -123,6 +123,26 @@ private final class FlowLevelStore: @unchecked Sendable {
}
}
/// Last observed audio tap timestamp. This lets the host publish "ready"
/// only after the microphone pipeline has produced real frames.
private final class FlowAudioProofStore: @unchecked Sendable {
private let lock = OSAllocatedUnfairLock(initialState: TimeInterval(0))
func markFrameReceived() {
lock.withLock { $0 = Date().timeIntervalSince1970 }
}
func reset() {
lock.withLock { $0 = 0 }
}
func hasRecentFrame(maxAge: TimeInterval) -> Bool {
let timestamp = lock.withLock { $0 }
guard timestamp > 0 else { return false }
return Date().timeIntervalSince1970 - timestamp <= maxAge
}
}
/// Route-adaptive downsampling converter, safe to call from the realtime tap.
///
/// `AVAudioEngine.installTap(format:)` traps with an **uncatchable** NSException
@@ -207,6 +227,7 @@ public final class FlowContinuousCapture {
private let streamRelay = FlowCaptureStreamRelay()
private let prerollStore = FlowPrerollStore()
private let levelStore = FlowLevelStore(barCount: FlowCaptureConstants.levelBarCount)
private let audioProofStore = FlowAudioProofStore()
private let gate = OSAllocatedUnfairLock(initialState: UtteranceGatePhase.idle)
private let drainTracker = FlowCaptureDrainTracker()
private let tailSampleCounter = OSAllocatedUnfairLock(initialState: 0)
@@ -229,12 +250,28 @@ public final class FlowContinuousCapture {
public var running: Bool { isRunning }
/// True when the capture session flag, tap, and audio engine are all live.
public var engineIsLive: Bool {
isRunning && didInstallTap && audioEngine.isRunning
}
/// True only when the engine is live and the input tap has recently
/// delivered an actual audio frame.
public func engineHasRecentAudio(maxAge: TimeInterval = 1) -> Bool {
engineIsLive && audioProofStore.hasRecentFrame(maxAge: maxAge)
}
/// Called on the main actor when `engineIsLive` may have changed.
public var onEngineLiveChanged: ((Bool) -> Void)?
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
public func start() throws {
guard !isRunning else { return }
audioProofStore.reset()
try activateEngine()
isRunning = true
installSessionObservers()
notifyEngineLiveChanged()
}
/// Bring up the audio session + engine for the *current* hardware route.
@@ -289,6 +326,7 @@ public final class FlowContinuousCapture {
let relay = streamRelay
let preroll = prerollStore
let levels = levelStore
let proof = audioProofStore
let tracker = drainTracker
let tailCounter = tailSampleCounter
let policy = drainPolicy
@@ -296,6 +334,7 @@ public final class FlowContinuousCapture {
downsampler: downsampler,
gate: gateLock,
levelStore: levels,
audioProofStore: proof,
prerollStore: preroll,
streamRelay: relay,
drainTracker: tracker,
@@ -332,6 +371,7 @@ public final class FlowContinuousCapture {
audioEngine.stop()
}
isRunning = false
audioProofStore.reset()
downsampler = nil
targetFormat = nil
hwFormat = nil
@@ -339,6 +379,7 @@ public final class FlowContinuousCapture {
false,
options: .notifyOthersOnDeactivation
)
notifyEngineLiveChanged()
}
/// Re-activate capture after returning from background without
@@ -355,6 +396,21 @@ public final class FlowContinuousCapture {
if !audioEngine.isRunning {
try? audioEngine.start()
}
notifyEngineLiveChanged()
}
public func awaitAudioFlowing(
timeout: TimeInterval,
recentFrameMaxAge: TimeInterval = 1
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
return true
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
}
// MARK: - Route / interruption recovery
@@ -434,6 +490,7 @@ public final class FlowContinuousCapture {
switch type {
case .began:
log.info("Audio interruption began")
notifyEngineLiveChanged()
case .ended:
guard isRunning else { return }
let shouldResume: Bool
@@ -462,11 +519,17 @@ public final class FlowContinuousCapture {
}
do {
try activateEngine()
notifyEngineLiveChanged()
} catch {
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
notifyEngineLiveChanged()
}
}
private func notifyEngineLiveChanged() {
onEngineLiveChanged?(engineIsLive)
}
/// Begin forwarding downsampled buffers to ASR for one utterance.
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
@@ -546,6 +609,7 @@ public final class FlowContinuousCapture {
downsampler: AdaptiveDownsampler,
gate: OSAllocatedUnfairLock<UtteranceGatePhase>,
levelStore: FlowLevelStore,
audioProofStore: FlowAudioProofStore,
prerollStore: FlowPrerollStore,
streamRelay: FlowCaptureStreamRelay,
drainTracker: FlowCaptureDrainTracker,
@@ -553,6 +617,7 @@ public final class FlowContinuousCapture {
drainPolicy: FlowCaptureTailDrainPolicy
) -> @Sendable (AVAudioPCMBuffer, AVAudioTime) -> Void {
return { buffer, _ in
audioProofStore.markFrameReceived()
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
// Derive the converter from the *live* buffer format so a mid-session