cdf833935a
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
161 lines
5.7 KiB
Swift
161 lines
5.7 KiB
Swift
// MacHotkeyService.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// Global hold-to-talk: while the configured Option (⌥) key is held, dictation
|
|
// runs. Mirrors Typeless / SayIt push-to-talk from any foreground app.
|
|
|
|
import AppKit
|
|
import Foundation
|
|
|
|
/// Which physical Option (⌥) key triggers global hold-to-talk.
|
|
///
|
|
/// Right Option is the default: the left key is a routine typing modifier
|
|
/// (special characters, app shortcuts), so firing on any Option press
|
|
/// constantly misfires during normal typing.
|
|
enum MacHotkeyTrigger: String, CaseIterable, Identifiable {
|
|
case rightOption
|
|
case leftOption
|
|
case eitherOption
|
|
|
|
var id: String { rawValue }
|
|
|
|
var labelKey: String {
|
|
switch self {
|
|
case .rightOption: return "mac.hotkeyTrigger.rightOption"
|
|
case .leftOption: return "mac.hotkeyTrigger.leftOption"
|
|
case .eitherOption: return "mac.hotkeyTrigger.eitherOption"
|
|
}
|
|
}
|
|
|
|
/// Main-window hint under the record button — must follow the picker,
|
|
/// or the UI tells left-Option users to hold the right key.
|
|
var hintKey: String {
|
|
switch self {
|
|
case .rightOption: return "mac.hint.hold.rightOption"
|
|
case .leftOption: return "mac.hint.hold.leftOption"
|
|
case .eitherOption: return "mac.hint.hold.eitherOption"
|
|
}
|
|
}
|
|
|
|
/// `@AppStorage`-compatible key; persisted via the view model's defaults.
|
|
static let storageKey = "mac.hotkeyTrigger"
|
|
|
|
/// Device-dependent modifier bits (IOKit `NX_DEVICELALTKEYMASK` /
|
|
/// `NX_DEVICERALTKEYMASK`) that `.flagsChanged` events carry alongside the
|
|
/// device-independent `.option` flag, telling left and right apart.
|
|
private static let leftOptionMask: UInt = 0x20
|
|
private static let rightOptionMask: UInt = 0x40
|
|
|
|
/// Whether this trigger's key is currently down in a `.flagsChanged` event.
|
|
func isPressed(in event: NSEvent) -> Bool {
|
|
guard event.modifierFlags.contains(.option) else { return false }
|
|
let raw = event.modifierFlags.rawValue
|
|
switch self {
|
|
case .rightOption: return raw & Self.rightOptionMask != 0
|
|
case .leftOption: return raw & Self.leftOptionMask != 0
|
|
case .eitherOption: return true
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class MacHotkeyService {
|
|
/// How long the trigger key must stay held before recording begins.
|
|
/// Filters out quick ⌥-taps and ⌥+key combos (special characters, app
|
|
/// shortcuts) that would otherwise start and immediately abort dictation.
|
|
private static let holdDebounce: Duration = .milliseconds(150)
|
|
|
|
var onPressBegan: (() -> Void)?
|
|
var onPressEnded: (() -> Void)?
|
|
var trigger: MacHotkeyTrigger = .rightOption
|
|
|
|
private var globalFlagsMonitor: Any?
|
|
private var localFlagsMonitor: Any?
|
|
/// The trigger key is physically down (debounce may still be pending).
|
|
private var triggerKeyDown = false
|
|
/// `onPressBegan` has fired and `onPressEnded` is owed.
|
|
private var pressActive = false
|
|
private var pendingBegin: Task<Void, Never>?
|
|
private var isEnabled = true
|
|
|
|
func setEnabled(_ enabled: Bool) {
|
|
isEnabled = enabled
|
|
if !enabled { cancelPress() }
|
|
}
|
|
|
|
func start() {
|
|
guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return }
|
|
// Global monitors require Accessibility; without it the call returns
|
|
// nil and Option-hold never fires outside our own windows.
|
|
let trusted = MacTextInsertionService.requestAccessibilityIfNeeded()
|
|
|
|
globalFlagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
|
Task { @MainActor in self?.handleFlagsChanged(event) }
|
|
}
|
|
localFlagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
|
Task { @MainActor in self?.handleFlagsChanged(event) }
|
|
return event
|
|
}
|
|
|
|
#if DEBUG
|
|
if !trusted || globalFlagsMonitor == nil {
|
|
NSLog("[OSGKeyboard] Hotkey global monitor unavailable — grant Accessibility in System Settings")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
func stop() {
|
|
if let globalFlagsMonitor {
|
|
NSEvent.removeMonitor(globalFlagsMonitor)
|
|
self.globalFlagsMonitor = nil
|
|
}
|
|
if let localFlagsMonitor {
|
|
NSEvent.removeMonitor(localFlagsMonitor)
|
|
self.localFlagsMonitor = nil
|
|
}
|
|
cancelPress()
|
|
}
|
|
|
|
private func handleFlagsChanged(_ event: NSEvent) {
|
|
guard isEnabled else { return }
|
|
let triggerDown = trigger.isPressed(in: event)
|
|
if triggerDown, !triggerKeyDown {
|
|
triggerKeyDown = true
|
|
scheduleBegin()
|
|
} else if !triggerDown, triggerKeyDown {
|
|
triggerKeyDown = false
|
|
pendingBegin?.cancel()
|
|
pendingBegin = nil
|
|
if pressActive {
|
|
pressActive = false
|
|
onPressEnded?()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Debounce: begin only after the key has stayed held for `holdDebounce`.
|
|
/// Releasing the key first cancels the pending start, so a quick
|
|
/// Option+key combo never triggers recording.
|
|
private func scheduleBegin() {
|
|
pendingBegin?.cancel()
|
|
pendingBegin = Task { [weak self] in
|
|
try? await Task.sleep(for: Self.holdDebounce)
|
|
guard let self, !Task.isCancelled else { return }
|
|
self.pendingBegin = nil
|
|
guard self.isEnabled, self.triggerKeyDown, !self.pressActive else { return }
|
|
self.pressActive = true
|
|
self.onPressBegan?()
|
|
}
|
|
}
|
|
|
|
private func cancelPress() {
|
|
pendingBegin?.cancel()
|
|
pendingBegin = nil
|
|
triggerKeyDown = false
|
|
if pressActive {
|
|
pressActive = false
|
|
onPressEnded?()
|
|
}
|
|
}
|
|
}
|