|
|
|
@ -3,23 +3,23 @@ import Carbon |
|
|
|
import ApplicationServices |
|
|
|
|
|
|
|
public enum DictationMode: String, CaseIterable { |
|
|
|
case pushToTalk = "pushToTalk" // Hold to record, release to transcribe |
|
|
|
case toggle = "toggle" // Press once to start, press again to stop |
|
|
|
case pushToTalk = "pushToTalk" // Hold to record, release to transcribe |
|
|
|
|
|
|
|
public var localizedTitle: String { |
|
|
|
switch self { |
|
|
|
case .pushToTalk: |
|
|
|
return "نگهداشتن برای صحبت (Hold to Talk)" |
|
|
|
case .toggle: |
|
|
|
return "فشردن برای شروع / توقف (Toggle)" |
|
|
|
case .pushToTalk: |
|
|
|
return "نگهداشتن برای صحبت (Push to Talk)" |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
public enum HotkeyPreset: String, CaseIterable { |
|
|
|
case controlSpace = "Control + Space" |
|
|
|
case option = "Option (⌥ نگهداشتن)" |
|
|
|
case capsLock = "Caps Lock" |
|
|
|
case controlSpace = "Control + Space" |
|
|
|
case optionSpace = "Option + Space" |
|
|
|
case cmdShiftSpace = "Cmd + Shift + Space" |
|
|
|
case f8 = "F8" |
|
|
|
@ -27,12 +27,12 @@ public enum HotkeyPreset: String, CaseIterable { |
|
|
|
|
|
|
|
public var keyCode: UInt32 { |
|
|
|
switch self { |
|
|
|
case .controlSpace, .optionSpace, .cmdShiftSpace: |
|
|
|
return UInt32(kVK_Space) // 49 |
|
|
|
case .option: |
|
|
|
return UInt32(kVK_Option) // 58 |
|
|
|
case .capsLock: |
|
|
|
return UInt32(kVK_CapsLock) // 57 |
|
|
|
case .controlSpace, .optionSpace, .cmdShiftSpace: |
|
|
|
return UInt32(kVK_Space) |
|
|
|
case .f8: |
|
|
|
return UInt32(kVK_F8) |
|
|
|
case .f5: |
|
|
|
@ -42,15 +42,13 @@ public enum HotkeyPreset: String, CaseIterable { |
|
|
|
|
|
|
|
public var carbonModifiers: UInt32 { |
|
|
|
switch self { |
|
|
|
case .option, .capsLock: |
|
|
|
return 0 |
|
|
|
case .controlSpace: |
|
|
|
return UInt32(controlKey) |
|
|
|
case .optionSpace: |
|
|
|
return UInt32(optionKey) |
|
|
|
case .cmdShiftSpace: |
|
|
|
return UInt32(cmdKey | shiftKey) |
|
|
|
case .f8, .f5: |
|
|
|
case .option, .capsLock, .f8, .f5: |
|
|
|
return 0 |
|
|
|
} |
|
|
|
} |
|
|
|
@ -70,12 +68,12 @@ public final class HotkeyManager { |
|
|
|
|
|
|
|
private var isOptionPhysicallyDown = false |
|
|
|
private var isCapsLockPhysicallyDown = false |
|
|
|
private var isKeyDown = false |
|
|
|
private var lastTriggerTime: TimeInterval = 0 |
|
|
|
|
|
|
|
public var currentPreset: HotkeyPreset { |
|
|
|
get { |
|
|
|
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue |
|
|
|
return HotkeyPreset(rawValue: val) ?? .option |
|
|
|
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.controlSpace.rawValue |
|
|
|
return HotkeyPreset(rawValue: val) ?? .controlSpace |
|
|
|
} |
|
|
|
set { |
|
|
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") |
|
|
|
@ -85,8 +83,8 @@ public final class HotkeyManager { |
|
|
|
|
|
|
|
public var currentMode: DictationMode { |
|
|
|
get { |
|
|
|
let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.pushToTalk.rawValue |
|
|
|
return DictationMode(rawValue: val) ?? .pushToTalk |
|
|
|
let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.toggle.rawValue |
|
|
|
return DictationMode(rawValue: val) ?? .toggle |
|
|
|
} |
|
|
|
set { |
|
|
|
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode") |
|
|
|
@ -100,7 +98,7 @@ public final class HotkeyManager { |
|
|
|
unregisterHotkeys() |
|
|
|
|
|
|
|
let preset = currentPreset |
|
|
|
print("Registering Hotkey for preset:", preset.rawValue, "mode:", currentMode.rawValue) |
|
|
|
print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'") |
|
|
|
|
|
|
|
// 1. Carbon HotKey for multi-key combos (Control+Space, etc.) |
|
|
|
if preset != .option && preset != .capsLock { |
|
|
|
@ -116,12 +114,12 @@ public final class HotkeyManager { |
|
|
|
|
|
|
|
let kind = GetEventKind(eventRef) |
|
|
|
if kind == UInt32(kEventHotKeyPressed) { |
|
|
|
DispatchQueue.main.async { |
|
|
|
manager.onHotkeyPressed?() |
|
|
|
} |
|
|
|
manager.triggerAction() |
|
|
|
} else if kind == UInt32(kEventHotKeyReleased) { |
|
|
|
DispatchQueue.main.async { |
|
|
|
manager.onHotkeyReleased?() |
|
|
|
if manager.currentMode == .pushToTalk { |
|
|
|
DispatchQueue.main.async { |
|
|
|
manager.onHotkeyReleased?() |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
return noErr |
|
|
|
@ -140,13 +138,22 @@ public final class HotkeyManager { |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
// 2. Global Event Tap for single modifier keys (Option, CapsLock) |
|
|
|
// 2. Global Event Tap for high-priority interception (Swallow Control+Space & modifier handling) |
|
|
|
setupEventTap() |
|
|
|
|
|
|
|
// 3. Secondary NSEvent Global Monitor as backup |
|
|
|
// 3. Fallback NSEvent monitor |
|
|
|
setupGlobalMonitor() |
|
|
|
} |
|
|
|
|
|
|
|
private func triggerAction() { |
|
|
|
let now = Date().timeIntervalSince1970 |
|
|
|
guard (now - lastTriggerTime) > 0.35 else { return } |
|
|
|
lastTriggerTime = now |
|
|
|
DispatchQueue.main.async { [weak self] in |
|
|
|
self?.onHotkeyPressed?() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private func setupEventTap() { |
|
|
|
let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) |
|
|
|
let selfPtr = Unmanaged.passUnretained(self).toOpaque() |
|
|
|
@ -158,16 +165,27 @@ public final class HotkeyManager { |
|
|
|
let flags = event.flags.rawValue |
|
|
|
let keyCode = event.getIntegerValueField(.keyboardEventKeycode) |
|
|
|
|
|
|
|
// 1. Option Key (Hold / Push-to-Talk) |
|
|
|
if manager.currentPreset == .option { |
|
|
|
let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 |
|
|
|
if isAlt != manager.isOptionPhysicallyDown { |
|
|
|
manager.isOptionPhysicallyDown = isAlt |
|
|
|
DispatchQueue.main.async { |
|
|
|
// 1. Control + Space (Swallow keystroke and toggle speech) |
|
|
|
if manager.currentPreset == .controlSpace { |
|
|
|
if keyCode == 49 && type == .keyDown { // 49 = Space |
|
|
|
let isCtrl = (flags & CGEventFlags.maskControl.rawValue) != 0 |
|
|
|
if isCtrl { |
|
|
|
manager.triggerAction() |
|
|
|
return nil // Swallow event so space is not typed in the active app |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 2. Option Key |
|
|
|
else if manager.currentPreset == .option { |
|
|
|
if keyCode == 58 || keyCode == 61 { |
|
|
|
let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 |
|
|
|
if isAlt != manager.isOptionPhysicallyDown { |
|
|
|
manager.isOptionPhysicallyDown = isAlt |
|
|
|
if isAlt { |
|
|
|
manager.onHotkeyPressed?() |
|
|
|
} else { |
|
|
|
if manager.currentMode == .pushToTalk { |
|
|
|
manager.triggerAction() |
|
|
|
} else if manager.currentMode == .pushToTalk { |
|
|
|
DispatchQueue.main.async { |
|
|
|
manager.onHotkeyReleased?() |
|
|
|
} |
|
|
|
} |
|
|
|
@ -175,14 +193,12 @@ public final class HotkeyManager { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 2. CapsLock Key |
|
|
|
// 3. CapsLock Key |
|
|
|
else if manager.currentPreset == .capsLock { |
|
|
|
if keyCode == 57 { |
|
|
|
if !manager.isCapsLockPhysicallyDown { |
|
|
|
manager.isCapsLockPhysicallyDown = true |
|
|
|
DispatchQueue.main.async { |
|
|
|
manager.onHotkeyPressed?() |
|
|
|
} |
|
|
|
manager.triggerAction() |
|
|
|
} else { |
|
|
|
manager.isCapsLockPhysicallyDown = false |
|
|
|
if manager.currentMode == .pushToTalk { |
|
|
|
@ -211,28 +227,17 @@ public final class HotkeyManager { |
|
|
|
self.runLoopSource = source |
|
|
|
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) |
|
|
|
CGEvent.tapEnable(tap: tap, enable: true) |
|
|
|
print("CGEventTap created and enabled successfully.") |
|
|
|
} else { |
|
|
|
print("CGEventTap creation failed. Falling back to NSEvent global monitor.") |
|
|
|
print("HotkeyManager: CGEventTap registered successfully.") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private func setupGlobalMonitor() { |
|
|
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { [weak self] event in |
|
|
|
guard let self = self else { return } |
|
|
|
|
|
|
|
// Only use as fallback if event tap is inactive |
|
|
|
if self.eventTapPort == nil { |
|
|
|
if self.currentPreset == .option { |
|
|
|
let isAlt = event.modifierFlags.contains(.option) |
|
|
|
if isAlt != self.isOptionPhysicallyDown { |
|
|
|
self.isOptionPhysicallyDown = isAlt |
|
|
|
if isAlt { |
|
|
|
self.onHotkeyPressed?() |
|
|
|
} else if self.currentMode == .pushToTalk { |
|
|
|
self.onHotkeyReleased?() |
|
|
|
} |
|
|
|
} |
|
|
|
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .flagsChanged]) { [weak self] event in |
|
|
|
guard let self = self, self.eventTapPort == nil else { return } |
|
|
|
|
|
|
|
if self.currentPreset == .controlSpace { |
|
|
|
if event.keyCode == 49 && event.modifierFlags.contains(.control) { |
|
|
|
self.triggerAction() |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|