diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bed186..e5b306a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ 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 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. / **润色不再代答转写内容**:趣味风格(直男癌/装逼指南/大厂黑话)曾把「你觉得这个包怎么样」润色成「还行,挺顺眼的」。现已在全局契约、全部内置风格包、提示词安全边界与路由问句守卫四层加入最高优先级的「只润色、不作答」规则,问句必须仍是问句。 ### Changed diff --git a/OSGKeyboardMac/MacAudioRecorder.swift b/OSGKeyboardMac/MacAudioRecorder.swift index 91c83f9..3b429e1 100644 --- a/OSGKeyboardMac/MacAudioRecorder.swift +++ b/OSGKeyboardMac/MacAudioRecorder.swift @@ -36,6 +36,12 @@ final class MacAudioRecorder: @unchecked Sendable { private let lock = NSLock() private var samples: [Float] = [] private var snapshotContinuation: AsyncStream.Continuation? + /// Identifies the live snapshot sink. `AsyncStream.Continuation` is not + /// equatable, so a termination handler compares generations to tell "my + /// stream ended" from "a newer stream already replaced me". + private var snapshotGeneration = 0 + /// Guarded by `lock`: the audio tap runs on the render thread and must stop + /// appending the moment `stop()` begins tearing the engine down. private var isRunning = false /// Hard cap on accumulated audio: 10 minutes @16 kHz ≈ 38 MB of Float32. /// Recording is push-to-talk, but a stuck hotkey (or a latched Option @@ -87,24 +93,65 @@ final class MacAudioRecorder: @unchecked Sendable { /// The stream is finished automatically in `stop()`. func makeSnapshotStream() -> AsyncStream { AsyncStream { continuation in - lock.withLock { - snapshotContinuation?.finish() - snapshotContinuation = continuation - } + let generation = installSnapshotSink(continuation) continuation.onTermination = { [weak self] _ in - self?.lock.withLock { - self?.snapshotContinuation = nil - } + self?.clearSnapshotSink(ifGeneration: generation) } } } - private func startEngine() throws { + /// Publishes `continuation` as the live sink and returns its generation. + /// + /// `finish()` invokes `onTermination` **synchronously on the calling + /// thread**, and that handler takes `lock`. Since `NSLock` is not + /// reentrant, any `finish()` made while holding `lock` deadlocks the + /// caller — on the main thread that freezes the whole app. So the outgoing + /// continuation is only handed over here and finished after the unlock. + private func installSnapshotSink( + _ continuation: AsyncStream.Continuation + ) -> Int { + let (previous, generation) = lock.withLock { + let previous = snapshotContinuation + snapshotGeneration += 1 + snapshotContinuation = continuation + return (previous, snapshotGeneration) + } + previous?.finish() + return generation + } + + /// Detaches the sink only if it is still the one this generation installed, + /// so a late termination from a replaced stream cannot mute the live one. + private func clearSnapshotSink(ifGeneration generation: Int) { lock.withLock { - samples.removeAll(keepingCapacity: true) - snapshotContinuation?.finish() + guard snapshotGeneration == generation else { return } snapshotContinuation = nil } + } + + #if DEBUG + /// Test seam: whether a live snapshot sink is currently attached. Lets the + /// regression tests assert that replacing a stream leaves the *new* sink in + /// place, which is otherwise invisible from outside. + var hasLiveSnapshotSink: Bool { + lock.withLock { snapshotContinuation != nil } + } + #endif + + /// Hands the live sink out for finishing outside the lock. See + /// `installSnapshotSink` for why `finish()` must never run under `lock`. + private func detachSnapshotSink() -> AsyncStream.Continuation? { + lock.withLock { + let detached = snapshotContinuation + snapshotContinuation = nil + return detached + } + } + + private func startEngine() throws { + let stale = detachSnapshotSink() + lock.withLock { samples.removeAll(keepingCapacity: true) } + stale?.finish() let input = engine.inputNode let inputFormat = input.outputFormat(forBus: 0) @@ -118,22 +165,33 @@ final class MacAudioRecorder: @unchecked Sendable { } engine.prepare() try engine.start() - isRunning = true + lock.withLock { isRunning = true } } /// Stops capture and returns the accumulated 16 kHz mono samples. func stop() -> [Float] { - guard isRunning else { return [] } + // Retire the tap first: `removeTap` / `engine.stop()` can still drain a + // buffer in flight, and a callback that appends into a torn-down engine + // is what logged `kAudioUnitErr_InvalidElement (-10877)`. + let wasRunning = lock.withLock { + guard isRunning else { return false } + isRunning = false + return true + } + guard wasRunning else { return [] } + engine.inputNode.removeTap(onBus: 0) engine.stop() - isRunning = false - return lock.withLock { - snapshotContinuation?.finish() - snapshotContinuation = nil + + let sink = detachSnapshotSink() + let out = lock.withLock { let out = samples samples.removeAll(keepingCapacity: false) return out } + // Outside the lock: `finish()` re-enters via `onTermination`. + sink?.finish() + return out } private func appendResampled(_ buffer: AVAudioPCMBuffer) { @@ -166,16 +224,18 @@ final class MacAudioRecorder: @unchecked Sendable { let rms = (sumSquares / Float(frameCount)).squareRoot() let normalized = min(1, max(0, rms * 12)) - lock.withLock { + let sink: AsyncStream.Continuation? = lock.withLock { + guard isRunning else { return nil } samples.append(contentsOf: chunk) if samples.count > Self.maxSampleCount + Self.trimHysteresisSamples { samples.removeFirst(samples.count - Self.maxSampleCount) } let factor: Float = normalized > smoothedLevel ? 0.5 : 0.15 smoothedLevel += (normalized - smoothedLevel) * factor - snapshotContinuation?.yield( - AudioBufferSnapshot(samples: chunk, sampleRate: 16_000) - ) + return snapshotContinuation } + // Yielded outside the lock so the render thread never holds it across a + // consumer hand-off, and never while the sink might terminate. + sink?.yield(AudioBufferSnapshot(samples: chunk, sampleRate: 16_000)) } } diff --git a/OSGKeyboardMac/MacDictationOverlayController.swift b/OSGKeyboardMac/MacDictationOverlayController.swift index 8ca9a18..da79ff7 100644 --- a/OSGKeyboardMac/MacDictationOverlayController.swift +++ b/OSGKeyboardMac/MacDictationOverlayController.swift @@ -22,7 +22,9 @@ final class MacDictationOverlayController { private var wasBusy = false private let bottomMargin: CGFloat = 36 - private let fallbackSize = NSSize(width: 400, height: 52) + /// The pill is a fixed size, so the panel never needs to resize while the + /// transcript grows — see `MacDictationOverlayView.panelSize`. + private let panelSize = MacDictationOverlayView.panelSize // MARK: - User-draggable position (persisted across launches) @@ -33,8 +35,6 @@ final class MacDictationOverlayController { /// pill grows / shrinks with the live transcript (symmetric resize). private var customCenterX: CGFloat = 0 private var customOriginY: CGFloat = 0 - /// The origin we last set programmatically (kept for clamping / bookkeeping). - private var lastProgrammaticOrigin: NSPoint? /// Cursor + window origin captured at the start of a manual drag, so we can /// follow the absolute cursor and stay immune to the window moving under it. private var dragCursorStart: NSPoint? @@ -72,15 +72,11 @@ final class MacDictationOverlayController { } .store(in: &cancellables) - // Keep waveform / app name / copy fresh while visible. - viewModel.objectWillChange - .receive(on: RunLoop.main) - .sink { [weak self] _ in - guard let self, self.panel?.isVisible == true else { return } - self.refreshContent(viewModel: viewModel) - self.resizeToFit() - } - .store(in: &cancellables) + // Waveform / app name / copy refresh through the view's own + // `@ObservedObject` binding. Re-driving them from `objectWillChange` + // used to reassign `rootView` and force a synchronous relayout ~20×/s + // (the level timer's cadence), which deadlocked AppKit layout during + // the state storm that fires when the hold-to-talk key is released. NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification) .receive(on: RunLoop.main) @@ -121,8 +117,9 @@ final class MacDictationOverlayController { private func present(viewModel: MacDictationViewModel) { ensurePanel(viewModel: viewModel) + // Once per show, not per state change: picks up an appearance or UI + // language switch made since the pill was last visible. refreshContent(viewModel: viewModel) - resizeToFit() reposition() guard let panel else { return } @@ -144,11 +141,11 @@ final class MacDictationOverlayController { if panel != nil { return } let host = NSHostingView(rootView: makeRoot(viewModel: viewModel)) - host.frame = NSRect(origin: .zero, size: fallbackSize) + host.frame = NSRect(origin: .zero, size: panelSize) hosting = host let panel = NSPanel( - contentRect: NSRect(origin: .zero, size: fallbackSize), + contentRect: NSRect(origin: .zero, size: panelSize), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false @@ -188,40 +185,11 @@ final class MacDictationOverlayController { ) } - private func resizeToFit() { - guard let panel, let hosting else { return } - hosting.layoutSubtreeIfNeeded() - let fitting = hosting.fittingSize - // Bounds include the 32pt horizontal transparent margin around the pill - // (16 per side) that gives the shadow room, so the pill body itself - // still spans ~300–520. - let width = fitting.width.isFinite && fitting.width > 1 - ? min(max(fitting.width, 332), 552) - : fallbackSize.width - let height = fitting.height.isFinite && fitting.height > 1 - ? max(fitting.height, fallbackSize.height) - : fallbackSize.height - var frame = panel.frame - // Grow / shrink around the anchor center so the pill stays put: the - // dragged center when custom, otherwise its current center. - let targetMidX = hasCustomPosition ? customCenterX : frame.midX - frame.size = NSSize(width: width, height: height) - if targetMidX.isFinite { - frame.origin.x = targetMidX - width / 2 - } - if let visible = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame { - frame.origin = clampedOrigin(frame.origin, size: frame.size, in: visible) - } - lastProgrammaticOrigin = frame.origin - panel.setFrame(frame, display: true) - hosting.frame = NSRect(origin: .zero, size: frame.size) - } - private func reposition() { guard let panel else { return } let screen = NSScreen.main ?? NSScreen.screens.first guard let visible = screen?.visibleFrame else { return } - let size = panel.frame.size + let size = panelSize // Respect the user's dragged spot; otherwise snap to bottom-center. let desired: NSPoint if hasCustomPosition { @@ -232,9 +200,7 @@ final class MacDictationOverlayController { y: visible.minY + bottomMargin ) } - let origin = clampedOrigin(desired, size: size, in: visible) - lastProgrammaticOrigin = origin - panel.setFrameOrigin(origin) + panel.setFrameOrigin(clampedOrigin(desired, size: size, in: visible)) } /// Keep the panel fully inside the screen's visible frame so a dragged / @@ -266,9 +232,7 @@ final class MacDictationOverlayController { ) let size = panel.frame.size let visible = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame - let origin = visible.map { clampedOrigin(target, size: size, in: $0) } ?? target - lastProgrammaticOrigin = origin - panel.setFrameOrigin(origin) + panel.setFrameOrigin(visible.map { clampedOrigin(target, size: size, in: $0) } ?? target) } /// Persist the dragged spot as center-X + bottom-left Y. @@ -287,7 +251,6 @@ final class MacDictationOverlayController { private func resetPositionToDefault() { hasCustomPosition = false clearPersistedPosition() - resizeToFit() reposition() } diff --git a/OSGKeyboardMac/MacDictationOverlayView.swift b/OSGKeyboardMac/MacDictationOverlayView.swift index a44e65f..1486139 100644 --- a/OSGKeyboardMac/MacDictationOverlayView.swift +++ b/OSGKeyboardMac/MacDictationOverlayView.swift @@ -17,6 +17,22 @@ struct MacDictationOverlayView: View { var onResetPosition: (() -> Void)? @Environment(\.themePalette) private var palette + /// Pill body width. Wide enough for dot + live badge + a 320pt transcript + /// line + waveform + stop button at `Spacing.sm` gaps. + static let pillWidth: CGFloat = 500 + /// Transparent margin around the pill, sized to contain the shadow's reach + /// (radius 14 + y 5). The panel is sized to pill + margin, and the shadow + /// would otherwise clip into hard translucent-black corners. + static let shadowMargin = EdgeInsets(top: 12, leading: 16, bottom: 20, trailing: 16) + /// Total panel size the hosting `NSPanel` should use. + static var panelSize: CGSize { + CGSize( + width: pillWidth + shadowMargin.leading + shadowMargin.trailing, + // 28pt content + 11pt vertical padding on each side. + height: 28 + 22 + shadowMargin.top + shadowMargin.bottom + ) + } + private var lang: AppUILanguage { viewModel.config.uiLanguage } private var isBusy: Bool { @@ -46,19 +62,17 @@ struct MacDictationOverlayView: View { .frame(height: 28) .padding(.horizontal, Spacing.md) .padding(.vertical, 11) - .frame(minWidth: 300, idealWidth: 400, maxWidth: 520) - .fixedSize(horizontal: true, vertical: true) + // Fixed width, not intrinsic: the hosting panel is sized from this + // constant once, so a growing transcript never asks AppKit to resize + // the window mid-update. Long text truncates in `primaryLine` instead. + .frame(width: Self.pillWidth) .background(palette.surface, in: Capsule(style: .continuous)) .overlay( Capsule(style: .continuous) .stroke(palette.dividerStrong, lineWidth: 0.5) ) .shadow(color: Color.black.opacity(0.22), radius: 14, y: 5) - // Transparent margin large enough to contain the shadow's reach - // (radius 14 + y 5). The panel is sized to `fittingSize`, which ignores - // shadow, so without this room the borderless window clips the shadow - // into hard translucent-black corners. - .padding(EdgeInsets(top: 12, leading: 16, bottom: 20, trailing: 16)) + .padding(Self.shadowMargin) .contentShape(Capsule(style: .continuous)) // Manual drag: `isMovableByWindowBackground` doesn't work on a // non-activating panel, so we move the panel ourselves. The controller diff --git a/OSGKeyboardMacTests/Info.plist b/OSGKeyboardMacTests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/OSGKeyboardMacTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/OSGKeyboardMacTests/MacAudioRecorderSnapshotStreamTests.swift b/OSGKeyboardMacTests/MacAudioRecorderSnapshotStreamTests.swift new file mode 100644 index 0000000..0f77c33 --- /dev/null +++ b/OSGKeyboardMacTests/MacAudioRecorderSnapshotStreamTests.swift @@ -0,0 +1,57 @@ +// MacAudioRecorderSnapshotStreamTests.swift +// OSGKeyboard · Mac tests +// +// Regression guard for the freeze that hit when the hold-to-talk key was +// released. `MacAudioRecorder` finished its snapshot continuation while holding +// a non-reentrant `NSLock`; `AsyncStream.Continuation.finish()` invokes +// `onTermination` synchronously on the calling thread, that handler re-took the +// same lock, and because the release path runs `stop()` on the main actor the +// whole app wedged. + +import XCTest +@testable import OSGKeyboard + +final class MacAudioRecorderSnapshotStreamTests: XCTestCase { + + /// Installing a second stream finishes the first one. Run off-main and + /// bounded by a semaphore timeout so a reintroduced lock re-entry fails the + /// test instead of hanging the whole suite. + func testReplacingSnapshotStreamDoesNotDeadlock() { + let recorder = MacAudioRecorder() + let firstStream = recorder.makeSnapshotStream() + let drain = Task { for await _ in firstStream {} } + + let installed = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = recorder.makeSnapshotStream() + installed.signal() + } + + XCTAssertEqual( + installed.wait(timeout: .now() + 2), + .success, + "Replacing the snapshot stream deadlocked: finish() ran while holding the recorder lock." + ) + drain.cancel() + } + + /// The outgoing stream's termination handler fires *during* the install of + /// its replacement, so it must recognise itself as stale and leave the new + /// sink attached — otherwise live ASR silently receives no audio. + func testReplacingSnapshotStreamKeepsTheNewSinkAttached() { + let recorder = MacAudioRecorder() + let firstStream = recorder.makeSnapshotStream() + let drain = Task { for await _ in firstStream {} } + + let secondStream = recorder.makeSnapshotStream() + + XCTAssertTrue( + recorder.hasLiveSnapshotSink, + "The replaced stream's termination detached the sink that had just replaced it." + ) + // The sink lives only as long as the stream: releasing `secondStream` + // early would terminate it and invalidate the assertion above. + withExtendedLifetime(secondStream) {} + drain.cancel() + } +} diff --git a/project.yml b/project.yml index 85e6f54..97ddf04 100644 --- a/project.yml +++ b/project.yml @@ -390,6 +390,33 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.ext.tests TARGETED_DEVICE_FAMILY: "1,2" + # ========================================================= + # macOS 单元测试 + # ========================================================= + # Hosted in the Mac app so `@testable import OSGKeyboard` reaches the + # macOS-only types (MacAudioRecorder, overlay sizing) that cannot compile + # against the iOS targets. + OSGKeyboardMacTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "15.0" + sources: + - path: OSGKeyboardMacTests + info: + path: OSGKeyboardMacTests/Info.plist + dependencies: + - target: OSGKeyboardMac + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.mac.tests + MACOSX_DEPLOYMENT_TARGET: "15.0" + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S + # The host target is named OSGKeyboardMac but ships as OSGKeyboard.app, + # so the path XcodeGen infers from the target name does not exist. + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/OSGKeyboard.app/Contents/MacOS/OSGKeyboard" + BUNDLE_LOADER: "$(TEST_HOST)" + # ========================================================= # macOS 菜单栏 App (Phase 1 · 云端 MVP) # ========================================================= @@ -523,5 +550,9 @@ schemes: OSGKeyboardMac: all run: config: Debug + test: + config: Debug + targets: + - OSGKeyboardMacTests archive: config: Release