Files
OSGKeyboard/OSGKeyboardShared/Services/FlowSessionDarwin.swift
T
Rocky c2f07bd8d2 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.
2026-07-08 18:13:56 +08:00

92 lines
3.0 KiB
Swift

// FlowSessionDarwin.swift
// OSGKeyboard · Shared
//
// Cross-process Darwin notification when the host app changes Flow session
// state (start, extend, end). Keyboard extension listens without polling alone.
import Foundation
public enum FlowSessionDarwin {
public static let notificationName = "com.osgkeyboard.flow.session.changed"
/// Posted when the host app writes a transcription result or error.
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
/// Posted when the host app publishes or clears the ready contract.
public static let hostReadyNotificationName = "com.osgkeyboard.flow.host.ready.changed"
public static func postSessionChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(notificationName as CFString),
nil,
nil,
true
)
}
public static func postTranscriptionChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(transcriptionNotificationName as CFString),
nil,
nil,
true
)
}
public static func postHostReadyChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(hostReadyNotificationName as CFString),
nil,
nil,
true
)
}
}
/// Observes Darwin notifications on a background thread; invokes
/// `handler` on the main actor.
public final class FlowSessionDarwinObserver {
private final class Box: @unchecked Sendable {
let handler: @MainActor () -> Void
init(handler: @escaping @MainActor () -> Void) { self.handler = handler }
}
private let box: Box
private let token: UnsafeMutableRawPointer
private let notificationName: CFString
public init(
notificationName: String = FlowSessionDarwin.notificationName,
handler: @escaping @MainActor () -> Void
) {
let box = Box(handler: handler)
self.box = box
self.token = Unmanaged.passRetained(box).toOpaque()
self.notificationName = notificationName as CFString
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
token,
{ _, observer, _, _, _ in
guard let observer else { return }
let box = Unmanaged<Box>.fromOpaque(observer).takeUnretainedValue()
Task { @MainActor in box.handler() }
},
self.notificationName,
nil,
.deliverImmediately
)
}
deinit {
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
token,
CFNotificationName(notificationName),
nil
)
Unmanaged<Box>.fromOpaque(token).release()
}
}