Files
OSGKeyboard/OSGKeyboardMac/MacDictationOverlayController.swift
T
Rocky 2d44423f4c fix(mac): prevent Option release deadlock and HUD layout storm
The hold-to-talk release path called AsyncStream.Continuation.finish()
while holding MacAudioRecorder's NSLock; onTermination re-entered the
same lock on the main thread and froze the app. Finish snapshot sinks
outside the lock and guard the audio tap once stop() begins.

The dictation HUD no longer reassigns its hosting view and forces a
synchronous relayout on every view-model tick; it uses a fixed panel
size and SwiftUI @ObservedObject refresh instead.

Adds OSGKeyboardMacTests with a regression test that fails under the
old lock re-entry pattern.
2026-07-29 16:50:32 +08:00

305 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MacDictationOverlayController.swift
// OSGKeyboard · Mac
//
// Owns a borderless, non-activating floating NSPanel that hosts the
// dictation HUD. Shown for any recording path (hotkey, menu bar, main
// window) and dismissed after a short success beat when processing ends.
import AppKit
import Combine
import SwiftUI
@MainActor
final class MacDictationOverlayController {
static let shared = MacDictationOverlayController()
private var panel: NSPanel?
private var hosting: NSHostingView<AnyView>?
private var cancellables = Set<AnyCancellable>()
private var hideWorkItem: DispatchWorkItem?
/// Keeps the pill visible briefly after a successful delivery.
private var showingCompletion = false
private var wasBusy = false
private let bottomMargin: CGFloat = 36
/// 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)
/// True once the user has dragged the HUD; suppresses the default
/// bottom-center snap so the panel stays where the user placed it.
private var hasCustomPosition = false
/// Stored as center-X + bottom-left Y so the anchor stays stable while the
/// pill grows / shrinks with the live transcript (symmetric resize).
private var customCenterX: CGFloat = 0
private var customOriginY: CGFloat = 0
/// 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?
private var dragWindowStart: NSPoint?
private static let hasCustomPositionKey = "mac.overlay.hasCustomPosition"
private static let centerXKey = "mac.overlay.centerX"
private static let originYKey = "mac.overlay.originY"
private init() {
let defaults = UserDefaults.standard
if defaults.bool(forKey: Self.hasCustomPositionKey) {
hasCustomPosition = true
customCenterX = CGFloat(defaults.double(forKey: Self.centerXKey))
customOriginY = CGFloat(defaults.double(forKey: Self.originYKey))
}
}
func start(observing viewModel: MacDictationViewModel) {
guard cancellables.isEmpty else { return }
Publishers.CombineLatest3(
viewModel.$isRecording,
viewModel.$isPreparingToRecord,
viewModel.$isProcessing
)
.receive(on: RunLoop.main)
.sink { [weak self] recording, preparing, processing in
self?.handleBusyChange(
recording: recording,
preparing: preparing,
processing: processing,
viewModel: viewModel
)
}
.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)
.sink { [weak self] _ in self?.reposition() }
.store(in: &cancellables)
}
private func handleBusyChange(
recording: Bool,
preparing: Bool,
processing: Bool,
viewModel: MacDictationViewModel
) {
let busy = recording || preparing || processing
if busy {
hideWorkItem?.cancel()
hideWorkItem = nil
showingCompletion = false
wasBusy = true
present(viewModel: viewModel)
return
}
// Transition: busy → idle. Flash a short "done" state, then hide.
if wasBusy {
wasBusy = false
showingCompletion = true
present(viewModel: viewModel)
scheduleHide()
return
}
if !showingCompletion {
hideImmediately()
}
}
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)
reposition()
guard let panel else { return }
if panel.isVisible {
// Already up — still bump to front in case another space stole it.
panel.orderFrontRegardless()
return
}
panel.alphaValue = 0
panel.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.22
panel.animator().alphaValue = 1
}
}
private func ensurePanel(viewModel: MacDictationViewModel) {
if panel != nil { return }
let host = NSHostingView(rootView: makeRoot(viewModel: viewModel))
host.frame = NSRect(origin: .zero, size: panelSize)
hosting = host
let panel = NSPanel(
contentRect: NSRect(origin: .zero, size: panelSize),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.contentView = host
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
// Above normal floating windows so the HUD stays visible over browsers /
// full-screen apps, without going as high as the screen saver.
panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.floatingWindow)) + 1)
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
// Background dragging can't move a non-activating panel; we drive the
// drag ourselves from a SwiftUI DragGesture (see `dragMoved`).
panel.isMovableByWindowBackground = false
panel.hidesOnDeactivate = false
panel.ignoresMouseEvents = false
panel.becomesKeyOnlyIfNeeded = true
self.panel = panel
}
private func refreshContent(viewModel: MacDictationViewModel) {
hosting?.rootView = makeRoot(viewModel: viewModel)
}
private func makeRoot(viewModel: MacDictationViewModel) -> AnyView {
AnyView(
MacDictationOverlayView(
viewModel: viewModel,
onDragChanged: { [weak self] in self?.dragMoved() },
onDragEnded: { [weak self] in self?.dragEnded() },
onResetPosition: { [weak self] in self?.resetPositionToDefault() }
)
.macSystemPalette()
.environment(\.locale, viewModel.config.uiLanguage.swiftUILocale)
.preferredColorScheme(MacAppearancePreference.current.colorScheme)
)
}
private func reposition() {
guard let panel else { return }
let screen = NSScreen.main ?? NSScreen.screens.first
guard let visible = screen?.visibleFrame else { return }
let size = panelSize
// Respect the user's dragged spot; otherwise snap to bottom-center.
let desired: NSPoint
if hasCustomPosition {
desired = NSPoint(x: customCenterX - size.width / 2, y: customOriginY)
} else {
desired = NSPoint(
x: visible.midX - size.width / 2,
y: visible.minY + bottomMargin
)
}
panel.setFrameOrigin(clampedOrigin(desired, size: size, in: visible))
}
/// Keep the panel fully inside the screen's visible frame so a dragged /
/// restored position can never strand it off-screen (e.g. after a display
/// or resolution change).
private func clampedOrigin(_ origin: NSPoint, size: NSSize, in visible: NSRect) -> NSPoint {
guard visible.width >= size.width, visible.height >= size.height else {
return origin
}
let x = min(max(origin.x, visible.minX), visible.maxX - size.width)
let y = min(max(origin.y, visible.minY), visible.maxY - size.height)
return NSPoint(x: x, y: y)
}
/// Follows the absolute cursor while dragging. Reading `NSEvent.mouseLocation`
/// (screen coordinates) instead of the gesture's local translation avoids the
/// feedback loop you'd get from moving the window the gesture lives in.
private func dragMoved() {
guard let panel else { return }
let cursor = NSEvent.mouseLocation
if dragCursorStart == nil {
dragCursorStart = cursor
dragWindowStart = panel.frame.origin
}
guard let cursorStart = dragCursorStart, let windowStart = dragWindowStart else { return }
let target = NSPoint(
x: windowStart.x + (cursor.x - cursorStart.x),
y: windowStart.y + (cursor.y - cursorStart.y)
)
let size = panel.frame.size
let visible = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame
panel.setFrameOrigin(visible.map { clampedOrigin(target, size: size, in: $0) } ?? target)
}
/// Persist the dragged spot as center-X + bottom-left Y.
private func dragEnded() {
dragCursorStart = nil
dragWindowStart = nil
guard let panel else { return }
customCenterX = panel.frame.midX
customOriginY = panel.frame.origin.y
hasCustomPosition = true
persistPosition()
}
/// Double-clicking the pill clears the custom spot and returns it to the
/// default bottom-center.
private func resetPositionToDefault() {
hasCustomPosition = false
clearPersistedPosition()
reposition()
}
private func persistPosition() {
let defaults = UserDefaults.standard
defaults.set(hasCustomPosition, forKey: Self.hasCustomPositionKey)
defaults.set(Double(customCenterX), forKey: Self.centerXKey)
defaults.set(Double(customOriginY), forKey: Self.originYKey)
}
private func clearPersistedPosition() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: Self.hasCustomPositionKey)
defaults.removeObject(forKey: Self.centerXKey)
defaults.removeObject(forKey: Self.originYKey)
}
private func scheduleHide() {
hideWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.showingCompletion = false
self?.hideAnimated()
}
hideWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1.15, execute: work)
}
private func hideImmediately() {
hideWorkItem?.cancel()
hideWorkItem = nil
showingCompletion = false
panel?.orderOut(nil)
panel?.alphaValue = 1
}
private func hideAnimated() {
guard let panel, panel.isVisible else {
hideImmediately()
return
}
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.2
panel.animator().alphaValue = 0
}, completionHandler: { [weak self] in
Task { @MainActor in
self?.panel?.orderOut(nil)
self?.panel?.alphaValue = 1
}
})
}
}