From 4c929d8b8b138957530799b981063591d2947581 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:30:36 +0800 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + .../ChunkedUtterancePipelineTests.swift | 55 +++++++++- OSGKeyboardMac/MacAppContextService.swift | 17 ++- OSGKeyboardMac/MacAudioRecorder.swift | 9 +- OSGKeyboardMac/MacDictationPipeline.swift | 21 +++- OSGKeyboardMac/MacDictationViewModel.swift | 94 +++++++++++++--- OSGKeyboardMac/MacMLXLiveCapture.swift | 16 ++- OSGKeyboardMac/MacTextInsertionService.swift | 27 ++++- OSGKeyboardMac/OSGKeyboardMacApp.swift | 10 +- .../MacDictationViewModelTests.swift | 101 ++++++++++++++++++ .../MacTextInsertionServiceTests.swift | 54 ++++++++++ .../Services/ChunkedUtterancePipeline.swift | 39 ++++++- .../Services/PolishOutputValidator.swift | 96 ++++++++++++++++- OSGKeyboardShared/en.lproj/Shared.strings | 2 +- .../zh-Hans.lproj/Shared.strings | 2 +- .../PolishOutputValidatorTests.swift | 79 ++++++++++++++ .../SpeechHistoryDayDeletionTests.swift | 59 ++++++++++ 17 files changed, 646 insertions(+), 39 deletions(-) create mode 100644 OSGKeyboardMacTests/MacDictationViewModelTests.swift create mode 100644 OSGKeyboardMacTests/MacTextInsertionServiceTests.swift create mode 100644 OSGKeyboardTests/SpeechHistoryDayDeletionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9dffd..f508abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. / **润色不再代答转写内容**:趣味风格(直男癌/装逼指南/大厂黑话)曾把「你觉得这个包怎么样」润色成「还行,挺顺眼的」。现已在全局契约、全部内置风格包、提示词安全边界与路由问句守卫四层加入最高优先级的「只润色、不作答」规则,问句必须仍是问句。 diff --git a/OSGKeyboardExtTests/ChunkedUtterancePipelineTests.swift b/OSGKeyboardExtTests/ChunkedUtterancePipelineTests.swift index cd5b4e7..26e8274 100644 --- a/OSGKeyboardExtTests/ChunkedUtterancePipelineTests.swift +++ b/OSGKeyboardExtTests/ChunkedUtterancePipelineTests.swift @@ -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.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, + locale: Locale + ) -> AsyncStream { + 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)") } } diff --git a/OSGKeyboardMac/MacAppContextService.swift b/OSGKeyboardMac/MacAppContextService.swift index 099ff1f..1c73ce5 100644 --- a/OSGKeyboardMac/MacAppContextService.swift +++ b/OSGKeyboardMac/MacAppContextService.swift @@ -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) + } } diff --git a/OSGKeyboardMac/MacAudioRecorder.swift b/OSGKeyboardMac/MacAudioRecorder.swift index 3b429e1..e1dc7d3 100644 --- a/OSGKeyboardMac/MacAudioRecorder.swift +++ b/OSGKeyboardMac/MacAudioRecorder.swift @@ -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 + func stop() -> [Float] +} + +final class MacAudioRecorder: MacAudioRecording, @unchecked Sendable { enum RecorderError: Error, LocalizedError { case converterUnavailable case microphoneAccessDenied diff --git a/OSGKeyboardMac/MacDictationPipeline.swift b/OSGKeyboardMac/MacDictationPipeline.swift index 81d74fa..6a43350 100644 --- a/OSGKeyboardMac/MacDictationPipeline.swift +++ b/OSGKeyboardMac/MacDictationPipeline.swift @@ -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, finishSignal: AsyncStream, 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 ) ) diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift index 18904b0..fb50bd4 100644 --- a/OSGKeyboardMac/MacDictationViewModel.swift +++ b/OSGKeyboardMac/MacDictationViewModel.swift @@ -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() /// 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? + /// 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? + /// 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? @@ -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) - wireHotkeyService() + 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.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( diff --git a/OSGKeyboardMac/MacMLXLiveCapture.swift b/OSGKeyboardMac/MacMLXLiveCapture.swift index c26eb81..4209519 100644 --- a/OSGKeyboardMac/MacMLXLiveCapture.swift +++ b/OSGKeyboardMac/MacMLXLiveCapture.swift @@ -14,10 +14,15 @@ enum MacMLXLiveCapture { audioStream: AsyncStream, finishSignal: AsyncStream, 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 ) ) diff --git a/OSGKeyboardMac/MacTextInsertionService.swift b/OSGKeyboardMac/MacTextInsertionService.swift index b338ad8..1fe3cec 100644 --- a/OSGKeyboardMac/MacTextInsertionService.swift +++ b/OSGKeyboardMac/MacTextInsertionService.swift @@ -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) - restoreItems(snapshot, to: pasteboard) + 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) } diff --git a/OSGKeyboardMac/OSGKeyboardMacApp.swift b/OSGKeyboardMac/OSGKeyboardMacApp.swift index e32882b..6f41355 100644 --- a/OSGKeyboardMac/OSGKeyboardMacApp.swift +++ b/OSGKeyboardMac/OSGKeyboardMacApp.swift @@ -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 diff --git a/OSGKeyboardMacTests/MacDictationViewModelTests.swift b/OSGKeyboardMacTests/MacDictationViewModelTests.swift new file mode 100644 index 0000000..4d1bd06 --- /dev/null +++ b/OSGKeyboardMacTests/MacDictationViewModelTests.swift @@ -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? + 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 { + AsyncStream { $0.finish() } + } + + func stop() -> [Float] { + lock.lock() + stops += 1 + lock.unlock() + return [] + } +} diff --git a/OSGKeyboardMacTests/MacTextInsertionServiceTests.swift b/OSGKeyboardMacTests/MacTextInsertionServiceTests.swift new file mode 100644 index 0000000..edd7df5 --- /dev/null +++ b/OSGKeyboardMacTests/MacTextInsertionServiceTests.swift @@ -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 + ) + } +} diff --git a/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift b/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift index 66a94c8..2ca3573 100644 --- a/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift +++ b/OSGKeyboardShared/Services/ChunkedUtterancePipeline.swift @@ -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) { diff --git a/OSGKeyboardShared/Services/PolishOutputValidator.swift b/OSGKeyboardShared/Services/PolishOutputValidator.swift index 19d5469..95c0055 100644 --- a/OSGKeyboardShared/Services/PolishOutputValidator.swift +++ b/OSGKeyboardShared/Services/PolishOutputValidator.swift @@ -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 { + let pattern = #"第\s*(\d+)\s*[::]\s*00"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let fullRange = NSRange(input.startIndex..() + + 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.. 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..