feat(macos): local ASR model manager, menu-bar polish, and release 0.5.2

Adds a bundled local ASR model catalog for the macOS app with one-click
Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a
shared model storage directory used by MLX Qwen3. Fixes the light-mode
sidebar material and makes the menu-bar icon follow the system appearance
with a refreshed status mark. Renames the built product to OSGKeyboard.app.

Bumps version to 0.5.2 (build 19).
This commit is contained in:
Rocky
2026-07-09 08:55:37 +08:00
parent c2f07bd8d2
commit 200265fbd6
50 changed files with 4666 additions and 266 deletions
@@ -42,6 +42,10 @@ final class KeyboardFlowCoordinator {
private static let hostReadyGrace: TimeInterval = 4
private var flowSessionMonitorTask: Task<Void, Never>?
private var isAwaitingFlowResult = false
private var activeSessionId: UUID?
private var currentUtteranceId: UUID?
private var currentCommandSeq: Int64 = 0
private var lastAvailabilityTraceSignature = ""
init(
state: KeyboardState,
@@ -133,7 +137,9 @@ final class KeyboardFlowCoordinator {
private func recomputeMicVoiceAvailability() {
FlowSessionBridge.reloadFromDisk()
let hostReady = FlowSessionBridge.isHostReady()
let readySnapshot = FlowSessionBridge.readySnapshot()
activeSessionId = readySnapshot?.sessionId ?? activeSessionId
let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
let now = Date().timeIntervalSince1970
if hostReady { lastHostReadyAt = now }
// Grace window: the host was ready very recently, so treat a momentary
@@ -145,7 +151,7 @@ final class KeyboardFlowCoordinator {
let hostWarming = !hostReady
&& FlowSessionBridge.isSessionActive()
&& (FlowSessionBridge.isHostReachable() || isPendingFlowStart || withinReadyGrace)
state.flowSessionActive = hostReady
state.flowSessionActive = FlowSessionBridge.isSessionActive()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase,
micDisabled: state.micDisabled,
@@ -154,6 +160,20 @@ final class KeyboardFlowCoordinator {
hostReady: hostReady,
isPreparingSession: isPendingFlowStart || hostWarming
)
let signature = [
"phase=\(String(describing: state.phase))",
"availability=\(String(describing: state.micVoiceAvailability))",
hostReady ? "hostReady=1" : "hostReady=0",
state.flowSessionActive ? "sessionActive=1" : "sessionActive=0",
isPendingFlowStart ? "pending=1" : "pending=0",
isFlowRecording ? "recording=1" : "recording=0",
isAwaitingFlowResult ? "awaiting=1" : "awaiting=0",
readySnapshot?.reason.rawValue ?? "snapshot=nil"
].joined(separator: "|")
if signature != lastAvailabilityTraceSignature {
lastAvailabilityTraceSignature = signature
traceState("availability.update", extra: signature)
}
}
/// Session is live but the ready contract has not landed yet poll
@@ -228,7 +248,8 @@ final class KeyboardFlowCoordinator {
scheduleAutoClearError()
recomputeMicVoiceAvailability()
case .unavailable(.preparingSession):
return
detectAndStoreAppContext()
beginFlowStart()
case .unavailable(.hostNotReady):
detectAndStoreAppContext()
beginFlowStart()
@@ -247,8 +268,8 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.stopped)
debug("pressEnded wrote .stopped (readback=\(FlowSessionBridge.recordingState().rawValue))")
writeCommand(.stopRecording)
debug("pressEnded wrote stop command")
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
recomputeMicVoiceAvailability()
@@ -256,7 +277,10 @@ final class KeyboardFlowCoordinator {
}
func beginFlowStart() {
guard !isPendingFlowStart else { return }
guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return
}
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
@@ -264,11 +288,11 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
openHostApp("startflow")
startFlowStartWatchdog()
debug("beginFlowStart")
traceState("beginFlowStart.started")
}
func handleHostAppOpenResult(path: String, success: Bool) {
debug("openHostApp path=\(path) success=\(success)")
traceState("openHostApp.result", extra: "path=\(path) success=\(success)")
guard !success else { return }
// The open genuinely failed (iOS blocked it / no Full Access). Don't
@@ -278,6 +302,7 @@ final class KeyboardFlowCoordinator {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
showManualOpenHint(path: "startflow")
recomputeMicVoiceAvailability()
return
@@ -290,9 +315,10 @@ final class KeyboardFlowCoordinator {
guard !isAwaitingFlowResult else { return }
if isFlowRecording || isPendingFlowStart {
if isFlowRecording {
FlowSessionBridge.setRecordingState(.aborted)
writeCommand(.abort)
ExtensionScreenWakeLock.release()
}
currentUtteranceId = nil
isFlowRecording = false
isPendingFlowStart = false
stopUtteranceCountdown()
@@ -304,17 +330,56 @@ final class KeyboardFlowCoordinator {
// MARK: - Private
private func nextCommandSeq() -> Int64 {
let millis = Int64(Date().timeIntervalSince1970 * 1_000)
currentCommandSeq = max(currentCommandSeq + 1, millis)
return currentCommandSeq
}
private func writeCommand(_ action: FlowCommand.Action) {
guard let activeSessionId, let currentUtteranceId else { return }
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: nextCommandSeq(),
action: action,
localeId: state.localeId
)
FlowSessionBridge.writeCommand(command)
debug(
"command \(action.rawValue) seq=\(command.commandSeq) " +
"utterance=\(currentUtteranceId.uuidString)"
)
}
private func consumePendingFlowDeliveryIfNeeded() {
if isAwaitingFlowResult {
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
if let result = matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
isAwaitingFlowResult = false
stopFlowWatchdog()
textInserter.handleFlowTranscript(delivery)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
)
)
FlowSessionBridge.clearResult()
currentUtteranceId = nil
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let error = FlowSessionBridge.consumeTranscriptionError() {
if let result = matchingResult(), isTerminalFailure(result) {
isAwaitingFlowResult = false
stopFlowWatchdog()
FlowSessionBridge.clearResult()
currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
kind: result.errorKind ?? .generic
)
state.phase = .error(
.fromFlowTranscription(error),
message: error.message
@@ -330,6 +395,20 @@ final class KeyboardFlowCoordinator {
}
}
private func matchingResult() -> FlowResult? {
guard let result = FlowSessionBridge.latestResult() else { return nil }
guard let activeSessionId, let currentUtteranceId else { return nil }
guard result.sessionId == activeSessionId,
result.utteranceId == currentUtteranceId else {
return nil
}
return result
}
private func isTerminalFailure(_ result: FlowResult) -> Bool {
result.status == .error || result.status == .timeout || result.status == .aborted
}
/// When the host process died mid-utterance, abort local recording / waiting
/// so the user is not stuck until the long result watchdog fires.
private func recoverFromDeadHostIfNeeded() {
@@ -339,7 +418,8 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.aborted)
writeCommand(.abort)
currentUtteranceId = nil
stopFlowWatchdog()
state.level = 0
state.phase = .idle
@@ -355,12 +435,14 @@ final class KeyboardFlowCoordinator {
}
private func failHostDisconnected() {
traceState("hostDisconnected.fail")
isAwaitingFlowResult = false
isFlowRecording = false
isPendingFlowStart = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.aborted)
writeCommand(.abort)
currentUtteranceId = nil
stopFlowWatchdog()
state.level = 0
let message = ExtL10n.string("keyboard.flow.hostDisconnected")
@@ -396,6 +478,7 @@ final class KeyboardFlowCoordinator {
private func startFlowRecording() {
recomputeMicVoiceAvailability()
guard state.micVoiceAvailability.isReady else {
traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))")
beginFlowStart()
return
}
@@ -403,8 +486,14 @@ final class KeyboardFlowCoordinator {
flowStartDeadline = 0
stopFlowWatchdog()
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
FlowSessionBridge.setRecordingState(.recording)
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot")
beginFlowStart()
return
}
activeSessionId = sessionId
currentUtteranceId = UUID()
writeCommand(.startRecording)
isFlowRecording = true
state.lastTranscript = ""
state.phase = .recording
@@ -414,9 +503,7 @@ final class KeyboardFlowCoordinator {
}
startUtteranceCountdown()
startFlowLevelWatchdog()
// Read back in-process to confirm the write landed before we rely on
// the host polling it out cross-process.
debug("startFlowRecording wrote .recording (readback=\(FlowSessionBridge.recordingState().rawValue))")
traceState("startFlowRecording.started")
}
private func startUtteranceCountdown() {
@@ -450,6 +537,7 @@ final class KeyboardFlowCoordinator {
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
traceState("pendingStart.cancelledByUser")
}
private func startFlowStartWatchdog() {
@@ -465,6 +553,7 @@ final class KeyboardFlowCoordinator {
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false
self.flowStartDeadline = 0
self.traceState("startWatchdog.timeout")
self.showManualOpenHint(path: "startflow")
return
}
@@ -480,7 +569,7 @@ final class KeyboardFlowCoordinator {
state.lastTranscript = ""
refreshSessionState()
startFlowRecording()
debug("completeFlowStartHandoff → auto startFlowRecording")
traceState("completeFlowStartHandoff.done")
}
private func startFlowLevelWatchdog() {
@@ -492,6 +581,12 @@ final class KeyboardFlowCoordinator {
self.state.level = Double(peak)
}
self.refreshFlowPartialIfNeeded()
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
if staleness > 5 {
self.debug("levelWatchdog: host heartbeat stale while recording")
self.failHostDisconnected()
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
@@ -501,7 +596,10 @@ final class KeyboardFlowCoordinator {
guard isFlowRecording || isAwaitingFlowResult else { return }
switch state.phase {
case .recording, .processing:
if let partial = FlowSessionBridge.transcriptionPartial() {
if let result = matchingResult(),
result.status == .partial,
let partial = result.text,
!partial.isEmpty {
state.lastTranscript = partial
}
default:
@@ -517,16 +615,33 @@ final class KeyboardFlowCoordinator {
debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)")
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
if let result = self.matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.debug("resultWatchdog consumed delivery len=\(delivery.text.count)")
self.textInserter.handleFlowTranscript(delivery)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
)
)
FlowSessionBridge.clearResult()
self.currentUtteranceId = nil
self.debug("resultWatchdog consumed delivery len=\(text.count)")
self.textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let error = FlowSessionBridge.consumeTranscriptionError() {
if let result = self.matchingResult(), self.isTerminalFailure(result) {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
FlowSessionBridge.clearResult()
self.currentUtteranceId = nil
let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
kind: result.errorKind ?? .generic
)
self.debug("resultWatchdog consumed error kind=\(error.kind.rawValue)")
self.state.phase = .error(
.fromFlowTranscription(error),
@@ -539,6 +654,11 @@ final class KeyboardFlowCoordinator {
self.refreshFlowPartialIfNeeded()
let now = Date().timeIntervalSince1970
let staleness = FlowSessionBridge.heartbeatStaleness() ?? .infinity
if self.isFlowRecording, staleness > 5 {
self.debug("level/result watchdog: host heartbeat stale while recording")
self.failHostDisconnected()
return
}
if staleness > FlowSessionKeys.heartbeatZombieInterval {
self.debug("resultWatchdog: host heartbeat zombie (staleness=\(String(format: "%.1f", staleness))s)")
self.failHostDisconnected()
@@ -553,6 +673,7 @@ final class KeyboardFlowCoordinator {
if now - startedAt > resultTimeout {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.currentUtteranceId = nil
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.flowResultTimeout, message: msg)
@@ -573,4 +694,29 @@ final class KeyboardFlowCoordinator {
private func debug(_ message: String) {
OSGLog.keyboardExt.info("\(message, privacy: .public)")
}
private func traceState(_ event: String, extra: String? = nil) {
let staleness = FlowSessionBridge.heartbeatStaleness().map { String(format: "%.1f", $0) } ?? "nil"
let sessionId = activeSessionId?.uuidString ?? "nil"
let utteranceId = currentUtteranceId?.uuidString ?? "nil"
let summary = [
"event=\(event)",
"phase=\(String(describing: state.phase))",
"availability=\(String(describing: state.micVoiceAvailability))",
"pending=\(isPendingFlowStart)",
"recording=\(isFlowRecording)",
"awaiting=\(isAwaitingFlowResult)",
"sessionId=\(sessionId)",
"utteranceId=\(utteranceId)",
"cmdSeq=\(currentCommandSeq)",
"sessionActive=\(FlowSessionBridge.isSessionActive())",
"hostReady=\(FlowSessionBridge.isHostReady())",
"heartbeatStaleness=\(staleness)"
].joined(separator: " ")
if let extra, !extra.isEmpty {
debug("[trace] \(summary) \(extra)")
} else {
debug("[trace] \(summary)")
}
}
}