fix(mac,asr): harden menu-bar delivery, chunk retry, and polish validator

Retain the external target app for menu-bar paste and polish context, retry
failed middle ASR chunks once, and stop false-positive path/number violations.
This commit is contained in:
Rocky
2026-07-29 18:30:36 +08:00
parent 34be2e8dd1
commit 4c929d8b8b
17 changed files with 646 additions and 39 deletions
+4
View File
@@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Mac Settings translation target**: polish-provider section includes “Polish then translate” with the same locale picker as Home / menu bar. / **Mac 设置翻译目标**:润色(LLM)分区新增「润色后翻译」,与首页 / 菜单栏同一套目标语言选择。
### Fixed
- **macOS menu-bar dictation delivery**: menu-bar sessions now retain the external target app before the popover takes focus, use that app for polish context and local bias, and reactivate it before pasting. / **macOS 菜单栏听写投递**:菜单栏会话会在弹窗抢焦点前保留外部目标应用,并将其用于润色上下文与本地偏置,粘贴前重新激活目标应用。
- **macOS recording and clipboard fallback**: cancelling microphone preparation no longer lets an untracked button task restart recording; clipboard restoration waits longer, preserves newer third-party writes, clears an originally empty clipboard correctly, and Accessibility failures explain that the transcript remains available for manual paste. / **macOS 录音与剪贴板降级**:取消麦克风准备后,未跟踪的按钮任务不会再次启动录音;剪贴板恢复延长等待时间、保留第三方较新的写入、正确还原原本为空的剪贴板,并在辅助功能权限失败时明确提示可手动粘贴识别结果。
- **Polish validator false positives**: slash-form dates, fractions, and words such as `and/or` are no longer treated as hard-protected file paths; confirmed ordinal ASR repairs also stop polluting missing-number telemetry. / **润色校验误报**:斜杠日期、分数及 `and/or` 等词不再被误判为文件路径硬违规;已确认的序号 ASR 修复也不再污染数字缺失遥测。
- **Transient chunk loss**: a failed middle ASR chunk now retries once with the same PCM before the serial worker advances, preventing brief network failures from silently removing several seconds of speech. / **瞬时分块丢字**:中段 ASR 分块失败后会使用同一份 PCM 原地重试一次,再继续串行处理,避免短暂网络抖动静默丢失数秒语音。
- **macOS Option release freeze**: finishing a live snapshot stream no longer calls `AsyncStream.Continuation.finish()` while holding the recorder lock — the termination handler re-entered the same `NSLock` on the main thread and wedged the app when the hold-to-talk key was released. / **macOS 松开 Option 卡死**:结束实时 snapshot 流时不再在持有 recorder 锁的情况下调用 `AsyncStream.Continuation.finish()`;终止回调会在主线程重入同一把 `NSLock`,松开听写键时导致整个 App 无响应。
- **macOS dictation HUD layout storm**: the floating pill no longer reassigns its hosting view and forces a synchronous relayout on every view-model tick (~20×/s from the level timer); it uses a fixed panel size and lets SwiftUI refresh through `@ObservedObject` instead. / **macOS 听写浮层布局风暴**:悬浮胶囊不再在每次 view-model 更新时重建 hosting 视图并强制同步重排(音量定时器约 20 次/秒);改为固定面板尺寸,由 SwiftUI `@ObservedObject` 驱动刷新。
- **Polish never answers the transcript**: fun styles (dating / flex / corp) could turn “你觉得这个包怎么样” into a reply such as “还行,挺顺眼的”. A top-priority “polish only, never answer” rule now sits in the global contract, every built-in style pack, the prompt safety boundary, and a router question guard that keeps question drafts as questions. / **润色不再代答转写内容**:趣味风格(直男癌/装逼指南/大厂黑话)曾把「你觉得这个包怎么样」润色成「还行,挺顺眼的」。现已在全局契约、全部内置风格包、提示词安全边界与路由问句守卫四层加入最高优先级的「只润色、不作答」规则,问句必须仍是问句。
@@ -72,7 +72,7 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
XCTAssertFalse(partials.isEmpty)
}
func testPipelineDeliversPartialSuccessWhenOneChunkFails() async {
func testPipelineRetriesTransientMiddleChunkFailure() async {
let pipeline = ChunkedUtterancePipeline(
asr: FailingSecondChunkASR(),
locale: Locale(identifier: "zh-Hans"),
@@ -86,6 +86,30 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
let outcome = await pipeline.transcribe(stream: stream) { _ in }
guard case .success(let success) = outcome else {
return XCTFail("expected partial success, got \(outcome)")
}
XCTAssertTrue(success.text.contains("recovered-middle"), "got \(success.text)")
XCTAssertTrue(success.chunkWarnings.isEmpty)
}
func testPipelineWarnsAfterMiddleChunkRetryAlsoFails() async {
let pipeline = ChunkedUtterancePipeline(
asr: PermanentlyFailingMiddleChunkASR(),
locale: Locale(identifier: "zh-Hans"),
config: config(overlapSeconds: 0)
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(
AudioBufferSnapshot(
samples: [Float](repeating: 0.1, count: 160),
sampleRate: 1_000
)
)
continuation.finish()
let outcome = await pipeline.transcribe(stream: stream) { _ in }
guard case .success(let success) = outcome else {
return XCTFail("expected partial success, got \(outcome)")
}
@@ -224,6 +248,35 @@ private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
if current == 1 {
return .failure("simulated chunk error")
}
if current == 2 {
return .success("recovered-middle")
}
return .success("seg\(samples.count)")
}
}
private struct PermanentlyFailingMiddleChunkASR: ASRService, @unchecked Sendable {
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
let current = callIndex.withLock { state in
let value = state
state += 1
return value
}
if current == 1 || current == 2 {
return .failure("persistent simulated chunk error")
}
return .success("seg\(samples.count)")
}
}
+16 -1
View File
@@ -68,7 +68,14 @@ enum MacAppContextService {
}
static func detectContext() -> AppContext {
guard let bundleId = frontmostBundleIdentifier() else { return .unknown }
detectContext(bundleIdentifier: frontmostBundleIdentifier())
}
/// Resolve polish context from the application captured for this dictation
/// session. This avoids reading OSGKeyboard itself after a popover steals
/// focus.
static func detectContext(bundleIdentifier bundleId: String?) -> AppContext {
guard let bundleId else { return .unknown }
if let mapped = contextByBundleId[bundleId] { return mapped }
if chatBundleIdsFromRegistry.contains(bundleId) { return .chat }
if bundleId.hasPrefix("com.apple.Safari") || bundleId.contains("chrome") {
@@ -83,4 +90,12 @@ enum MacAppContextService {
let context = detectContext()
store.setDetectedAppContext(context)
}
static func captureAndPersist(
application: NSRunningApplication?,
to store: AppGroupStore
) {
let context = detectContext(bundleIdentifier: application?.bundleIdentifier)
store.setDetectedAppContext(context)
}
}
+8 -1
View File
@@ -8,7 +8,14 @@
@preconcurrency import AVFoundation
final class MacAudioRecorder: @unchecked Sendable {
protocol MacAudioRecording: Sendable {
func level() -> Float
func start() async throws
func makeSnapshotStream() -> AsyncStream<AudioBufferSnapshot>
func stop() -> [Float]
}
final class MacAudioRecorder: MacAudioRecording, @unchecked Sendable {
enum RecorderError: Error, LocalizedError {
case converterUnavailable
case microphoneAccessDenied
+16 -5
View File
@@ -60,6 +60,7 @@ enum MacDictationPipeline {
static func run(
samples: [Float],
store: AppGroupStore,
targetAppBundleIdentifier: String? = nil,
onPartial: (@Sendable (String) -> Void)? = nil
) async throws -> MacDictationResult {
guard !samples.isEmpty else { throw MacDictationError.noAudio }
@@ -69,7 +70,11 @@ enum MacDictationPipeline {
var localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
localBias = resolveLocalBias(store: store, locale: locale)
localBias = resolveLocalBias(
store: store,
locale: locale,
targetAppBundleIdentifier: targetAppBundleIdentifier
)
raw = try await MacLocalASRService.transcribe(
samples: samples,
locale: locale,
@@ -103,6 +108,7 @@ enum MacDictationPipeline {
stream: AsyncStream<AudioBufferSnapshot>,
finishSignal: AsyncStream<Void>,
store: AppGroupStore,
targetAppBundleIdentifier: String?,
onPartial: @escaping @Sendable (String) -> Void
) async -> MacLiveASRCaptureResult {
if store.engineMode == "local", MacLocalASRService.usesMLXLiveStreaming() {
@@ -110,6 +116,7 @@ enum MacDictationPipeline {
audioStream: stream,
finishSignal: finishSignal,
store: store,
targetAppBundleIdentifier: targetAppBundleIdentifier,
onPartial: onPartial
)
}
@@ -117,7 +124,11 @@ enum MacDictationPipeline {
let locale = resolvedLocale(store: store)
let localBias: LocalASRBiasPayload?
if store.engineMode == "local" {
localBias = resolveLocalBias(store: store, locale: locale)
localBias = resolveLocalBias(
store: store,
locale: locale,
targetAppBundleIdentifier: targetAppBundleIdentifier
)
} else {
localBias = nil
}
@@ -280,15 +291,15 @@ enum MacDictationPipeline {
private static func resolveLocalBias(
store: AppGroupStore,
locale: Locale
locale: Locale,
targetAppBundleIdentifier: String?
) -> LocalASRBiasPayload? {
MacAppContextService.captureAndPersist(to: store)
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
frontAppBundleId: targetAppBundleIdentifier,
capabilities: capabilities
)
)
+78 -14
View File
@@ -75,14 +75,21 @@ final class MacDictationViewModel: ObservableObject {
@Published var config: ProviderConfig
let defaults: UserDefaults
private let recorder = MacAudioRecorder()
private let hotkeyService = MacHotkeyService()
private let recorder: any MacAudioRecording
private let hotkeyService: MacHotkeyService
private var levelTimer: Timer?
private var sessionTimer: Timer?
private var cancellables = Set<AnyCancellable>()
/// In-flight `beginRecording` started by the hotkey cancelled if the
/// key is released before the engine is ready (avoids a stuck session).
private var hotkeyBeginTask: Task<Void, Never>?
/// Button-triggered preparation needs the same cancellation semantics as
/// the hotkey path when the user clicks Stop before the engine is ready.
private var buttonBeginTask: Task<Void, Never>?
/// Captured before the menu-bar popover activates OSGKeyboard.
private var preparedPopoverTargetApplication: NSRunningApplication?
/// Frozen for one take so app switches during ASR cannot redirect delivery.
private var sessionTargetApplication: NSRunningApplication?
/// Live chunked / streaming ASR while recording (cloud or MLX local).
/// Finished in `finishRecording` so partials can become the final draft.
private var liveCaptureTask: Task<MacLiveASRCaptureResult, Never>?
@@ -97,8 +104,15 @@ final class MacDictationViewModel: ObservableObject {
static let hotkeyTrigger = MacHotkeyTrigger.storageKey
}
init(defaults: UserDefaults = .standard) {
init(
defaults: UserDefaults = .standard,
recorder: any MacAudioRecording = MacAudioRecorder(),
hotkeyService: MacHotkeyService = MacHotkeyService(),
startHotkeyService: Bool = true
) {
self.defaults = defaults
self.recorder = recorder
self.hotkeyService = hotkeyService
self.config = ProviderConfig(defaults: defaults)
self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true
@@ -111,7 +125,9 @@ final class MacDictationViewModel: ObservableObject {
MacICloudSyncBootstrap.configure(defaults: defaults)
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
if startHotkeyService {
wireHotkeyService()
}
forwardNestedObjectChanges()
}
@@ -132,6 +148,17 @@ final class MacDictationViewModel: ObservableObject {
refreshForegroundAppName()
}
/// Called immediately before the menu-bar popover activates the app.
func prepareForPopoverPresentation() {
let target = MacTextInsertionService.captureTargetApplication()
preparedPopoverTargetApplication = target
foregroundAppName = target?.localizedName
}
func clearPreparedPopoverTarget() {
preparedPopoverTargetApplication = nil
}
func reloadConfigFromCloud() {
config.reloadFromPersistedStorage()
statusMessage = MacL10n.string("mac.status.ready", language: config.uiLanguage)
@@ -256,7 +283,10 @@ final class MacDictationViewModel: ObservableObject {
if isRecording || isPreparingToRecord {
cancelOrFinishRecording()
} else {
Task { await beginRecording() }
buttonBeginTask?.cancel()
buttonBeginTask = Task { [weak self] in
await self?.beginRecording()
}
}
}
@@ -264,8 +294,12 @@ final class MacDictationViewModel: ObservableObject {
guard !isProcessing, !isRecording, !isPreparingToRecord else { return }
isPreparingToRecord = true
let store = AppGroupStore(defaults: defaults)
MacAppContextService.captureAndPersist(to: store)
refreshForegroundAppName()
let targetApplication = preparedPopoverTargetApplication
?? MacTextInsertionService.captureTargetApplication()
preparedPopoverTargetApplication = nil
sessionTargetApplication = targetApplication
MacAppContextService.captureAndPersist(application: targetApplication, to: store)
foregroundAppName = targetApplication?.localizedName
do {
try await recorder.start()
@@ -274,14 +308,20 @@ final class MacDictationViewModel: ObservableObject {
isPreparingToRecord = false
if Task.isCancelled {
_ = recorder.stop()
sessionTargetApplication = nil
buttonBeginTask = nil
return
}
buttonBeginTask = nil
isRecording = true
transcript = ""
isStreamingPartial = false
statusMessage = MacL10n.string("mac.status.listening", language: config.uiLanguage)
startTimers()
startLiveCaptureIfSupported(store: store)
startLiveCaptureIfSupported(
store: store,
targetAppBundleIdentifier: targetApplication?.bundleIdentifier
)
// Tiny race: Option released between the cancel check and
// `isRecording = true`. Treat it as end-of-hold and finish.
if Task.isCancelled {
@@ -289,6 +329,8 @@ final class MacDictationViewModel: ObservableObject {
}
} catch {
isPreparingToRecord = false
sessionTargetApplication = nil
buttonBeginTask = nil
if !Task.isCancelled {
statusMessage = error.localizedDescription
}
@@ -307,6 +349,9 @@ final class MacDictationViewModel: ObservableObject {
stopTimers()
audioLevel = 0
let store = AppGroupStore(defaults: defaults)
let targetApplication = sessionTargetApplication
let targetAppBundleIdentifier = targetApplication?.bundleIdentifier
sessionTargetApplication = nil
let usesDeferredStop = MacDictationPipeline.supportsLivePartials(store: store)
&& store.engineMode == "local"
&& MacLocalASRService.usesMLXLiveStreaming()
@@ -350,6 +395,7 @@ final class MacDictationViewModel: ObservableObject {
result = try await MacDictationPipeline.run(
samples: capturedSamples,
store: store,
targetAppBundleIdentifier: targetAppBundleIdentifier,
onPartial: { [weak self] partial in
Task { @MainActor in
self?.transcript = partial
@@ -362,6 +408,7 @@ final class MacDictationViewModel: ObservableObject {
result = try await MacDictationPipeline.run(
samples: capturedSamples,
store: store,
targetAppBundleIdentifier: targetAppBundleIdentifier,
onPartial: { [weak self] partial in
Task { @MainActor in
self?.transcript = partial
@@ -370,7 +417,10 @@ final class MacDictationViewModel: ObservableObject {
)
}
self.transcript = result.text
let pasted = try await self.deliver(result.text)
let pasted = try await self.deliver(
result.text,
targetApplication: targetApplication
)
self.recordUsage(for: result.text)
self.speechHistory.append(text: result.text)
self.appendToOverview(result.text)
@@ -393,7 +443,10 @@ final class MacDictationViewModel: ObservableObject {
}
}
private func startLiveCaptureIfSupported(store: AppGroupStore) {
private func startLiveCaptureIfSupported(
store: AppGroupStore,
targetAppBundleIdentifier: String?
) {
guard MacDictationPipeline.supportsLivePartials(store: store) else { return }
let stream = recorder.makeSnapshotStream()
let (finishStream, finishContinuation) = AsyncStream<Void>.makeStream(
@@ -405,6 +458,7 @@ final class MacDictationViewModel: ObservableObject {
stream: stream,
finishSignal: finishStream,
store: store,
targetAppBundleIdentifier: targetAppBundleIdentifier,
onPartial: { [weak self] partial in
Task { @MainActor in
guard let self else { return }
@@ -449,22 +503,32 @@ final class MacDictationViewModel: ObservableObject {
/// Stops an in-flight prepare, or finishes an active recording.
private func cancelOrFinishRecording() {
if isRecording {
buttonBeginTask = nil
finishRecording()
return
}
if isPreparingToRecord {
hotkeyBeginTask?.cancel()
hotkeyBeginTask = nil
// If the button-triggered prepare wasn't tracked by hotkeyBeginTask,
// still clear the preparing flag and stop any engine that raced in.
isPreparingToRecord = false
buttonBeginTask?.cancel()
buttonBeginTask = nil
// Keep the preparation gate closed until the cancelled start call
// actually returns; otherwise a rapid third click can start a
// second recorder task while the first one is still unwinding.
cancelLiveCapture()
_ = recorder.stop()
}
}
private func deliver(_ text: String) async throws -> Bool {
try await MacTextInsertionService.insert(text, autoPaste: autoPasteEnabled)
private func deliver(
_ text: String,
targetApplication: NSRunningApplication?
) async throws -> Bool {
try await MacTextInsertionService.insert(
text,
autoPaste: autoPasteEnabled,
targetApp: targetApplication
)
}
private func statusAfterDelivery(
+12 -4
View File
@@ -14,10 +14,15 @@ enum MacMLXLiveCapture {
audioStream: AsyncStream<AudioBufferSnapshot>,
finishSignal: AsyncStream<Void>,
store: AppGroupStore,
targetAppBundleIdentifier: String?,
onPartial: @escaping @Sendable (String) -> Void
) async -> MacLiveASRCaptureResult {
let locale = Locale(identifier: store.localeId.isEmpty ? "zh-CN" : store.localeId)
let bias = resolveBias(store: store, locale: locale)
let bias = resolveBias(
store: store,
locale: locale,
targetAppBundleIdentifier: targetAppBundleIdentifier
)
guard let model = MacLocalASRService.selectedModelDefinition(),
model.backend == .mlx,
@@ -133,14 +138,17 @@ enum MacMLXLiveCapture {
}
}
private static func resolveBias(store: AppGroupStore, locale: Locale) -> LocalASRBiasPayload? {
MacAppContextService.captureAndPersist(to: store)
private static func resolveBias(
store: AppGroupStore,
locale: Locale,
targetAppBundleIdentifier: String?
) -> LocalASRBiasPayload? {
let capabilities = MacLocalASRService.currentCapabilities()
let bias = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: store.personalDictionary,
locale: locale,
frontAppBundleId: MacAppContextService.frontmostBundleIdentifier(),
frontAppBundleId: targetAppBundleIdentifier,
capabilities: capabilities
)
)
+22 -3
View File
@@ -12,6 +12,10 @@ import Carbon
import Foundation
enum MacTextInsertionService {
/// Paste has no completion callback. Keep the transcript available long
/// enough for slower apps to consume the event before restoring clipboard.
static let pasteboardRestoreDelayNanoseconds: UInt64 = 500_000_000
enum InsertionError: Error, LocalizedError {
case accessibilityNotGranted
@@ -72,6 +76,7 @@ enum MacTextInsertionService {
let snapshot = snapshotItems(of: pasteboard)
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
let transcriptChangeCount = pasteboard.changeCount
guard autoPaste else { return false }
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
@@ -84,11 +89,25 @@ enum MacTextInsertionService {
// Give the target app time to read the transcript off the
// pasteboard, then restore whatever the user had on it.
try? await Task.sleep(nanoseconds: 300_000_000)
try? await Task.sleep(nanoseconds: pasteboardRestoreDelayNanoseconds)
if shouldRestorePasteboard(
transcriptChangeCount: transcriptChangeCount,
currentChangeCount: pasteboard.changeCount
) {
restoreItems(snapshot, to: pasteboard)
}
return true
}
/// Do not overwrite clipboard content written by the user, target app, or
/// a clipboard manager while the synthesized paste was in flight.
static func shouldRestorePasteboard(
transcriptChangeCount: Int,
currentChangeCount: Int
) -> Bool {
transcriptChangeCount == currentChangeCount
}
/// Brings `app` forward and waits (up to ~1 s) until it is frontmost so
/// the synthesized keystroke isn't swallowed mid-switch.
@MainActor
@@ -119,12 +138,12 @@ enum MacTextInsertionService {
}
}
private static func restoreItems(
static func restoreItems(
_ items: [[NSPasteboard.PasteboardType: Data]],
to pasteboard: NSPasteboard
) {
guard !items.isEmpty else { return }
pasteboard.clearContents()
guard !items.isEmpty else { return }
pasteboard.writeObjects(items.map { flavours in
let item = NSPasteboardItem()
for (type, data) in flavours { item.setData(data, forType: type) }
+9 -1
View File
@@ -104,7 +104,7 @@ enum MacMainWindow {
/// AppKit rather than SwiftUI's `MenuBarExtra` because the latter is flaky
/// when combined with a primary `Window` scene (the icon can silently vanish).
@MainActor
final class MacAppDelegate: NSObject, NSApplicationDelegate {
final class MacAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusItem: NSStatusItem?
private let popover = NSPopover()
@@ -183,6 +183,7 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
}
private func configurePopover() {
popover.delegate = self
popover.behavior = .transient
popover.animates = true
popover.contentSize = NSSize(width: 340, height: 420)
@@ -194,11 +195,18 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
if popover.isShown {
popover.performClose(sender)
} else {
// Capture before activation: once the popover becomes key,
// NSWorkspace reports OSGKeyboard instead of the user's target.
MacDictationViewModel.shared.prepareForPopoverPresentation()
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
func popoverDidClose(_ notification: Notification) {
MacDictationViewModel.shared.clearPreparedPopoverTarget()
}
}
/// SwiftUI content hosted inside the status-bar popover. Shares the single
@@ -0,0 +1,101 @@
// MacDictationViewModelTests.swift
// OSGKeyboard · Mac tests
//
// Regression coverage for cancelling an asynchronous recorder start.
import Foundation
import XCTest
@testable import OSGKeyboard
@MainActor
final class MacDictationViewModelTests: XCTestCase {
func testCancellingButtonPreparationKeepsGateClosedUntilStartUnwinds() async {
let suiteName = "com.osgkeyboard.mac.tests.prepare.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let recorder = SuspendedMacAudioRecorder()
let viewModel = MacDictationViewModel(
defaults: defaults,
recorder: recorder,
startHotkeyService: false
)
viewModel.toggleRecording()
let didStartPreparing = await waitUntil { recorder.isStartPending }
XCTAssertTrue(didStartPreparing)
XCTAssertTrue(viewModel.isPreparingToRecord)
viewModel.toggleRecording()
XCTAssertTrue(
viewModel.isPreparingToRecord,
"Cancellation must not reopen the start gate while recorder.start() is still unwinding."
)
recorder.completeStart()
let didFinishCancelling = await waitUntil { !viewModel.isPreparingToRecord }
XCTAssertTrue(didFinishCancelling)
XCTAssertFalse(viewModel.isRecording)
XCTAssertFalse(viewModel.isProcessing)
XCTAssertGreaterThanOrEqual(recorder.stopCallCount, 1)
}
private func waitUntil(
_ predicate: @escaping @MainActor () -> Bool
) async -> Bool {
for _ in 0..<100 {
if predicate() { return true }
try? await Task.sleep(for: .milliseconds(5))
}
return false
}
}
private final class SuspendedMacAudioRecorder: MacAudioRecording, @unchecked Sendable {
private let lock = NSLock()
private var startContinuation: CheckedContinuation<Void, any Error>?
private var stops = 0
var isStartPending: Bool {
lock.lock()
defer { lock.unlock() }
return startContinuation != nil
}
var stopCallCount: Int {
lock.lock()
defer { lock.unlock() }
return stops
}
func level() -> Float { 0 }
func start() async throws {
try await withCheckedThrowingContinuation { continuation in
lock.lock()
startContinuation = continuation
lock.unlock()
}
}
func completeStart() {
lock.lock()
let continuation = startContinuation
startContinuation = nil
lock.unlock()
continuation?.resume()
}
func makeSnapshotStream() -> AsyncStream<AudioBufferSnapshot> {
AsyncStream { $0.finish() }
}
func stop() -> [Float] {
lock.lock()
stops += 1
lock.unlock()
return []
}
}
@@ -0,0 +1,54 @@
// MacTextInsertionServiceTests.swift
// OSGKeyboard · Mac tests
//
// Regression coverage for clipboard preservation and captured-app context.
import AppKit
import XCTest
@testable import OSGKeyboard
final class MacTextInsertionServiceTests: XCTestCase {
func testRestoreRequiresTranscriptToStillOwnPasteboard() {
XCTAssertTrue(
MacTextInsertionService.shouldRestorePasteboard(
transcriptChangeCount: 12,
currentChangeCount: 12
)
)
XCTAssertFalse(
MacTextInsertionService.shouldRestorePasteboard(
transcriptChangeCount: 12,
currentChangeCount: 13
),
"A newer clipboard write must not be overwritten by restoration."
)
}
func testRestoringOriginallyEmptyPasteboardClearsTranscript() {
let pasteboard = NSPasteboard(
name: NSPasteboard.Name("MacTextInsertionServiceTests.\(UUID().uuidString)")
)
pasteboard.clearContents()
pasteboard.setString("transcript", forType: .string)
MacTextInsertionService.restoreItems([], to: pasteboard)
XCTAssertNil(pasteboard.string(forType: .string))
}
func testCapturedBundleIdentifierDrivesPolishContext() {
XCTAssertEqual(
MacAppContextService.detectContext(bundleIdentifier: "com.apple.dt.Xcode"),
.code
)
XCTAssertEqual(
MacAppContextService.detectContext(bundleIdentifier: "com.tencent.xinWeChat"),
.chat
)
XCTAssertEqual(
MacAppContextService.detectContext(bundleIdentifier: "com.osgkeyboard.mac"),
.unknown
)
}
}
@@ -143,7 +143,10 @@ public actor ChunkedUtterancePipeline {
action: "preMerge",
chunkIndex: chunk.index
)
let mergedResult = await transcribeChunk(samples: preMerge.samples)
let mergedResult = await transcribeChunkWithRetry(
samples: preMerge.samples,
chunkIndex: chunk.index
)
switch mergedResult {
case .success(let text):
// Empty / whitespace merge must NOT wipe a prior good segment
@@ -182,7 +185,10 @@ public actor ChunkedUtterancePipeline {
continue
}
let result = await transcribeChunk(samples: chunk.samples)
let result = await transcribeChunkWithRetry(
samples: chunk.samples,
chunkIndex: chunk.index
)
logChunkOutcome(chunk: chunk, result: result)
switch result {
case .success(let text):
@@ -199,7 +205,10 @@ public actor ChunkedUtterancePipeline {
action: "emptyRetry",
chunkIndex: chunk.index
)
let retryResult = await transcribeChunk(samples: retry.samples)
let retryResult = await transcribeChunkWithRetry(
samples: retry.samples,
chunkIndex: chunk.index
)
switch retryResult {
case .success(let retryText):
let trimmed = retryText.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -311,6 +320,30 @@ public actor ChunkedUtterancePipeline {
}.value
}
/// Retry one failed chunk before advancing the serial worker. Keeping the
/// same PCM samples prevents a transient request failure from creating an
/// undetectable hole in an otherwise fluent stitched transcript.
private func transcribeChunkWithRetry(
samples: [Float],
chunkIndex: Int
) async -> ASRChunkResult {
let first = await transcribeChunk(samples: samples)
guard case .failure(let message) = first else { return first }
guard !cancelled, !Task.isCancelled else { return .cancelled }
FlowTrace.warn(
"pipeline.chunk.retry",
"chunk=\(chunkIndex) samples=\(samples.count) error=\(message)"
)
do {
try await Task.sleep(nanoseconds: 150_000_000)
} catch {
return .cancelled
}
guard !cancelled, !Task.isCancelled else { return .cancelled }
return await transcribeChunk(samples: samples)
}
/// Pairs each chunk's audio with the text it produced, so an empty
/// transcript can be attributed to either silent audio or a mute engine.
private func logChunkOutcome(chunk: UtteranceAudioChunk, result: ASRChunkResult) {
@@ -62,7 +62,10 @@ public enum PolishOutputValidator {
}
let inputNumbers = matches(#"\d+(?:[.,]\d+)*"#, in: input)
let missingNumbers = Array(Set(inputNumbers.filter { !output.contains($0) })).sorted()
let allowedOrdinalNumbers = allowedOrdinalRepairNumbers(input: input, output: output)
let missingNumbers = Array(Set(inputNumbers.filter {
!output.contains($0) && !allowedOrdinalNumbers.contains($0)
})).sorted()
if !missingNumbers.isEmpty {
violations.append(.missingNumbers(missingNumbers))
}
@@ -106,7 +109,6 @@ public enum PolishOutputValidator {
let patterns = [
#"https?://[^\s<>"']+"#,
#"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"#,
#"(?:^|[\s(])(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+"#,
#"\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b"#,
#"\b[A-Za-z]+[a-z0-9][A-Z][A-Za-z0-9]*\b"#,
]
@@ -118,9 +120,99 @@ public enum PolishOutputValidator {
)))
}
}
let pathPattern = #"(?:^|[\s(])(?:~?/|\.\.?/)?(?:[A-Za-z0-9_.-]+/)+[A-Za-z0-9_.-]+"#
for rawValue in matches(pathPattern, in: text) {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines.union(
CharacterSet(charactersIn: "(")
))
if isProtectedPath(value) {
result.insert(value)
}
}
return result
}
private static func isProtectedPath(_ value: String) -> Bool {
let explicitPrefix = value.hasPrefix("/")
|| value.hasPrefix("./")
|| value.hasPrefix("../")
|| value.hasPrefix("~/")
let normalized = value.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let segments = normalized.split(separator: "/", omittingEmptySubsequences: true)
guard segments.count >= 2 else { return false }
// Dates and fractions such as 2025/03/01, 3/4, and 3/5 are numeric
// values, not file paths. They remain covered by soft number telemetry.
if segments.allSatisfy({ $0.allSatisfy(\.isNumber) }) {
return false
}
if explicitPrefix { return true }
if segments.count >= 3 { return true }
return segments.contains { $0.contains(".") || $0.contains("_") }
}
private static func allowedOrdinalRepairNumbers(
input: String,
output: String
) -> Set<String> {
let pattern = #"\s*(\d+)\s*[:]\s*00"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let fullRange = NSRange(input.startIndex..<input.endIndex, in: input)
var allowed = Set<String>()
for match in regex.matches(in: input, range: fullRange) {
guard match.numberOfRanges > 1,
let ordinalRange = Range(match.range(at: 1), in: input),
let matchRange = Range(match.range, in: input) else {
continue
}
let ordinal = String(input[ordinalRange])
let prefixRange = input.startIndex..<matchRange.lowerBound
let prefix = String(input[prefixRange])
guard hasEstablishedEnumeration(prefix) else { continue }
let escaped = NSRegularExpression.escapedPattern(for: ordinal)
let arabicListPattern = #"(?m)(?:^|\n)\s*"# + escaped + #"\s*[.)]"#
let chineseOrdinal = Int(ordinal).flatMap(chineseNumeral)
let hasArabicOrdinal = output.range(
of: arabicListPattern,
options: .regularExpression
) != nil
let hasChineseOrdinal = chineseOrdinal.map {
output.contains("\($0)")
} ?? false
if hasArabicOrdinal || hasChineseOrdinal {
allowed.insert(ordinal)
allowed.insert("00")
}
}
return allowed
}
private static func hasEstablishedEnumeration(_ prefix: String) -> Bool {
prefix.range(
of: #"(?:|[]+|)"#,
options: .regularExpression
) != nil
}
private static func chineseNumeral(_ value: Int) -> String? {
let digits = ["", "", "", "", "", "", "", "", "", ""]
switch value {
case 0...9:
return digits[value]
case 10:
return ""
case 11...19:
return "" + digits[value % 10]
case 20...99:
let tens = digits[value / 10] + ""
return value % 10 == 0 ? tens : tens + digits[value % 10]
default:
return nil
}
}
private static func matches(_ pattern: String, in text: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
+1 -1
View File
@@ -396,7 +396,7 @@
"mac.localASR.phase.finalizing" = "Finalizing";
"mac.localASR.phase.failed" = "Failed";
"mac.localASR.phase.completed" = "Completed";
"mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings";
"mac.error.accessibilityRequired" = "Dictation copied to the clipboard. Press ⌘V to paste, then enable Accessibility for OSGKeyboard in System Settings.";
"mac.foregroundApp" = "Front app: %@";
"mac.sync.settingsTitle" = "Cross-Device iCloud Sync";
"mac.sync.settingsSubtitle" = "Sync settings, history, and API keys across devices via iCloud.";
@@ -396,7 +396,7 @@
"mac.localASR.phase.finalizing" = "完成安装";
"mac.localASR.phase.failed" = "失败";
"mac.localASR.phase.completed" = "已完成";
"mac.error.accessibilityRequired" = "在系统设置中为 OSGKeyboard 启用辅助功能";
"mac.error.accessibilityRequired" = "识别结果已复制到剪贴板,请按 ⌘V 粘贴,然后在系统设置中为 OSGKeyboard 启用辅助功能";
"mac.foregroundApp" = "前台应用:%@";
"mac.sync.settingsTitle" = "跨设备iCloud 同步";
"mac.sync.settingsSubtitle" = "通过 iCloud 跨设备同步设置、历史记录、API Key";
@@ -38,6 +38,85 @@ final class PolishOutputValidatorTests: XCTestCase {
})
}
func testDatesFractionsAndSlashWordsAreNotProtectedPaths() {
let cases = [
("在 2025/03/01 之前完成", "在2025年3月1日之前完成"),
("价格是 3/4 杯面粉", "价格是四分之三杯面粉"),
("我给 3/5 分", "我给五分之三"),
("读一下 and/or 的用法", "读一下 and or 的用法"),
]
for (input, output) in cases {
let violations = PolishOutputValidator.validate(
input: input,
output: output,
dictionary: .empty,
lengthRatio: 0.2...3
)
XCTAssertFalse(
violations.contains {
if case .missingIdentifiers = $0 { return true }
return false
},
"Must not classify slash value as a protected path: \(input)"
)
}
}
func testStrongPathSignalsRemainHardProtectedIdentifiers() {
let inputs = [
"/usr/local/bin",
"../Sources/App.swift",
"Sources/Features/Auth",
"src/user_id",
]
for input in inputs {
let violations = PolishOutputValidator.validate(
input: "打开 \(input)",
output: "打开对应文件",
dictionary: .empty,
lengthRatio: 0.2...3
)
XCTAssertTrue(
violations.contains {
if case .missingIdentifiers(let values) = $0 {
return values.contains(input)
}
return false
},
"Expected hard path protection for \(input)"
)
}
}
func testOrdinalASRRepairDoesNotReportMissingZeroes() {
let violations = PolishOutputValidator.validate(
input: "第一点是A第2:00是B",
output: "第一点是 A\n2. B",
dictionary: .empty,
lengthRatio: 0.2...3
)
XCTAssertFalse(violations.contains {
if case .missingNumbers = $0 { return true }
return false
})
}
func testRealTimeStillReportsMissingZeroes() {
let violations = PolishOutputValidator.validate(
input: "第一点是坐第2:00班车",
output: "第一点是坐第二班车",
dictionary: .empty,
lengthRatio: 0.2...3
)
XCTAssertTrue(violations.contains {
if case .missingNumbers(let values) = $0 {
return values.contains("00")
}
return false
})
}
func testNumbersLengthAndLanguageAreObservationOnly() {
let violations = PolishOutputValidator.validate(
input: "项目 123 明天下午交付并通知全部相关成员",
@@ -0,0 +1,59 @@
// SpeechHistoryDayDeletionTests.swift
// OSGKeyboardTests
//
// Day-boundary and tombstone coverage for History's delete-day action.
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class SpeechHistoryDayDeletionTests: XCTestCase {
func testDeleteEntriesRemovesOnlySelectedLocalDayAndRecordsTombstones() {
let suiteName = "group.com.osgkeyboard.shared.tests.delete-day.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let calendar = Calendar.current
let selectedDay = Date(timeIntervalSince1970: 1_752_163_200)
let start = calendar.startOfDay(for: selectedDay)
let end = calendar.date(byAdding: .day, value: 1, to: start)!
let previous = SpeechHistoryEntry(
text: "previous",
createdAt: start.addingTimeInterval(-1)
)
let firstSelected = SpeechHistoryEntry(
text: "first selected",
createdAt: start.addingTimeInterval(1)
)
let lastSelected = SpeechHistoryEntry(
text: "last selected",
createdAt: end.addingTimeInterval(-1)
)
let next = SpeechHistoryEntry(
text: "next",
createdAt: end
)
SpeechHistoryStorage.save(
SyncedSpeechHistory(
entries: [next, lastSelected, firstSelected, previous]
),
to: defaults
)
let store = SpeechHistoryStore(defaults: defaults)
store.deleteEntries(on: selectedDay)
let persisted = SpeechHistoryStorage.load(from: defaults)
XCTAssertEqual(
Set(persisted.entries.map(\.id)),
Set([previous.id, next.id])
)
XCTAssertNotNil(persisted.deletedEntryIDs[firstSelected.id])
XCTAssertNotNil(persisted.deletedEntryIDs[lastSelected.id])
XCTAssertNil(persisted.deletedEntryIDs[previous.id])
XCTAssertNil(persisted.deletedEntryIDs[next.id])
}
}