c2f07bd8d2
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.
92 lines
3.0 KiB
Swift
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()
|
|
}
|
|
}
|