Browse Source

feat: hardware microphone key remap, full English menu, adaptive menu bar icon, and permission status items

main
Ali Alavi 18 hours ago
parent
commit
884b859875
  1. 60
      src/HotkeyManager.swift
  2. 43
      src/SonioxSettings.swift
  3. 27
      src/StatusBarController.swift

60
src/HotkeyManager.swift

@ -17,10 +17,10 @@ public enum DictationMode: String, CaseIterable {
} }
public enum HotkeyPreset: String, CaseIterable { public enum HotkeyPreset: String, CaseIterable {
case micKey = "Microphone Key (🎙️ / F5)"
case option = "Option (Hold ⌥)" case option = "Option (Hold ⌥)"
case doubleTapFn = "Double-Tap Fn (Globe 🌐)" case doubleTapFn = "Double-Tap Fn (Globe 🌐)"
case holdFn = "Hold Fn (Globe 🌐)" case holdFn = "Hold Fn (Globe 🌐)"
case f5 = "F5 (Fn + F5)"
case controlSpace = "Control + Space" case controlSpace = "Control + Space"
case capsLock = "Caps Lock" case capsLock = "Caps Lock"
case optionSpace = "Option + Space" case optionSpace = "Option + Space"
@ -29,6 +29,8 @@ public enum HotkeyPreset: String, CaseIterable {
public var keyCode: UInt32 { public var keyCode: UInt32 {
switch self { switch self {
case .micKey:
return UInt32(kVK_F5) // 96
case .controlSpace, .optionSpace, .cmdShiftSpace: case .controlSpace, .optionSpace, .cmdShiftSpace:
return UInt32(kVK_Space) // 49 return UInt32(kVK_Space) // 49
case .option: case .option:
@ -39,8 +41,6 @@ public enum HotkeyPreset: String, CaseIterable {
return UInt32(kVK_CapsLock) // 57 return UInt32(kVK_CapsLock) // 57
case .f8: case .f8:
return UInt32(kVK_F8) return UInt32(kVK_F8)
case .f5:
return UInt32(kVK_F5)
} }
} }
@ -52,7 +52,7 @@ public enum HotkeyPreset: String, CaseIterable {
return UInt32(optionKey) return UInt32(optionKey)
case .cmdShiftSpace: case .cmdShiftSpace:
return UInt32(cmdKey | shiftKey) return UInt32(cmdKey | shiftKey)
case .option, .doubleTapFn, .holdFn, .capsLock, .f8, .f5:
case .micKey, .option, .doubleTapFn, .holdFn, .capsLock, .f8:
return 0 return 0
} }
} }
@ -79,8 +79,8 @@ public final class HotkeyManager {
public var currentPreset: HotkeyPreset { public var currentPreset: HotkeyPreset {
get { get {
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue
return HotkeyPreset(rawValue: val) ?? .option
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.micKey.rawValue
return HotkeyPreset(rawValue: val) ?? .micKey
} }
set { set {
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset")
@ -108,7 +108,7 @@ public final class HotkeyManager {
print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'") print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'")
// 1. Carbon HotKey for multi-key combos (Control+Space, Option+Space, Cmd+Shift+Space, F8) // 1. Carbon HotKey for multi-key combos (Control+Space, Option+Space, Cmd+Shift+Space, F8)
if preset != .option && preset != .capsLock && preset != .f5 && preset != .doubleTapFn && preset != .holdFn {
if preset != .option && preset != .capsLock && preset != .micKey && preset != .doubleTapFn && preset != .holdFn {
var eventTypes = [ var eventTypes = [
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)) EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
@ -145,7 +145,7 @@ public final class HotkeyManager {
) )
} }
// 2. Global Event Tap for high-priority interception (Option, CapsLock, F5, Fn/Globe)
// 2. Global Event Tap for high-priority interception (Microphone Key, Option, CapsLock, Fn/Globe)
setupEventTap() setupEventTap()
// 3. Fallback NSEvent monitor // 3. Fallback NSEvent monitor
@ -154,7 +154,7 @@ public final class HotkeyManager {
private func triggerAction() { private func triggerAction() {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
guard (now - lastTriggerTime) > 0.30 else { return }
guard (now - lastTriggerTime) > 0.25 else { return }
lastTriggerTime = now lastTriggerTime = now
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
self?.onHotkeyPressed?() self?.onHotkeyPressed?()
@ -172,13 +172,23 @@ public final class HotkeyManager {
let flags = event.flags.rawValue let flags = event.flags.rawValue
let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
// 1. Control + Space
if manager.currentPreset == .controlSpace {
if keyCode == 49 && type == .keyDown { // 49 = Space
let isCtrl = (flags & CGEventFlags.maskControl.rawValue) != 0
if isCtrl {
// 1. Microphone Key (🎙 / F5)
if manager.currentPreset == .micKey {
if keyCode == 96 { // 96 = kVK_F5
if type == .keyDown {
if !manager.isF5PhysicallyDown {
manager.isF5PhysicallyDown = true
manager.triggerAction() manager.triggerAction()
return nil // Swallow event
}
return nil // Swallow F5 so it doesn't leak to apps
} else if type == .keyUp {
manager.isF5PhysicallyDown = false
if manager.currentMode == .pushToTalk {
DispatchQueue.main.async {
manager.onHotkeyReleased?()
}
}
return nil // Swallow F5 keyUp
} }
} }
} }
@ -218,22 +228,12 @@ public final class HotkeyManager {
} }
} }
// 4. F5 Key (Fn + F5)
else if manager.currentPreset == .f5 {
if keyCode == 96 { // 96 = kVK_F5
if type == .keyDown {
if !manager.isF5PhysicallyDown {
manager.isF5PhysicallyDown = true
// 4. Control + Space
else if manager.currentPreset == .controlSpace {
if keyCode == 49 && type == .keyDown { // 49 = Space
let isCtrl = (flags & CGEventFlags.maskControl.rawValue) != 0
if isCtrl {
manager.triggerAction() manager.triggerAction()
}
return nil
} else if type == .keyUp {
manager.isF5PhysicallyDown = false
if manager.currentMode == .pushToTalk {
DispatchQueue.main.async {
manager.onHotkeyReleased?()
}
}
return nil return nil
} }
} }

43
src/SonioxSettings.swift

@ -39,6 +39,10 @@ public final class SonioxSettings {
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
private init() {
ensureHardwareKeyRemapping()
}
public var language: RecognitionLanguage { public var language: RecognitionLanguage {
get { get {
let val = defaults.string(forKey: "SonioxLanguage") ?? RecognitionLanguage.multi.rawValue let val = defaults.string(forKey: "SonioxLanguage") ?? RecognitionLanguage.multi.rawValue
@ -99,10 +103,49 @@ public final class SonioxSettings {
defaults.removeObject(forKey: "SonioxPlaySounds") defaults.removeObject(forKey: "SonioxPlaySounds")
defaults.removeObject(forKey: "SonioxLaunchAtLogin") defaults.removeObject(forKey: "SonioxLaunchAtLogin")
updateLaunchAtLogin(enabled: false) updateLaunchAtLogin(enabled: false)
ensureHardwareKeyRemapping()
HotkeyManager.shared.registerHotkeys() HotkeyManager.shared.registerHotkeys()
SonioxSessionPool.shared.reconnectAll() SonioxSessionPool.shared.reconnectAll()
} }
public func ensureHardwareKeyRemapping() {
// Remap hardware microphone/dictation key (0xC000000CF and 0x10000009B) to F5 (0x70000003E)
let script = "/usr/bin/hidutil property --set '{\"UserKeyMapping\":[{\"HIDKeyboardModifierMappingSrc\":3221225679,\"HIDKeyboardModifierMappingDst\":1879048254},{\"HIDKeyboardModifierMappingSrc\":4294967451,\"HIDKeyboardModifierMappingDst\":1879048254}]}' >/dev/null 2>&1"
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/sh")
task.arguments = ["-c", script]
try? task.run()
installRemappingLaunchAgent()
}
private func installRemappingLaunchAgent() {
let launchAgentDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents")
let plistURL = launchAgentDir.appendingPathComponent("com.soniox.keymapping.plist")
try? FileManager.default.createDirectory(at: launchAgentDir, withIntermediateDirectories: true)
let plistContent = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.soniox.keymapping</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/hidutil</string>
<string>property</string>
<string>--set</string>
<string>{"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":3221225679,"HIDKeyboardModifierMappingDst":1879048254},{"HIDKeyboardModifierMappingSrc":4294967451,"HIDKeyboardModifierMappingDst":1879048254}]}</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
"""
try? plistContent.write(to: plistURL, atomically: true, encoding: .utf8)
}
private func updateLaunchAtLogin(enabled: Bool) { private func updateLaunchAtLogin(enabled: Bool) {
let launchAgentDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents") let launchAgentDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents")
let plistURL = launchAgentDir.appendingPathComponent("com.soniox.voice.plist") let plistURL = launchAgentDir.appendingPathComponent("com.soniox.voice.plist")

27
src/StatusBarController.swift

@ -1,4 +1,6 @@
import Cocoa import Cocoa
import AVFoundation
import ApplicationServices
public final class StatusBarController { public final class StatusBarController {
private var statusItem: NSStatusItem? private var statusItem: NSStatusItem?
@ -128,9 +130,15 @@ public final class StatusBarController {
advancedMenuItem.submenu = advancedMenu advancedMenuItem.submenu = advancedMenu
menu.addItem(advancedMenuItem) menu.addItem(advancedMenuItem)
// 6. System Permissions & Launch at Login
// 6. System Permissions
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
let micTitle = (micStatus == .authorized) ? "✅ Microphone Access: Granted" : "⚠️ Microphone Access: Click to Grant"
let micItem = NSMenuItem(title: micTitle, action: #selector(openMicrophoneSettings), keyEquivalent: "")
micItem.target = self
menu.addItem(micItem)
let isAxTrusted = AXIsProcessTrusted() let isAxTrusted = AXIsProcessTrusted()
let axTitle = isAxTrusted ? "✅ Accessibility Granted" : "🔑 Grant Accessibility Access..."
let axTitle = isAxTrusted ? "✅ Accessibility: Granted" : "⚠️ Accessibility: Click to Grant"
let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "") let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "")
axItem.target = self axItem.target = self
menu.addItem(axItem) menu.addItem(axItem)
@ -210,7 +218,22 @@ public final class StatusBarController {
buildMenu() buildMenu()
} }
@objc private func openMicrophoneSettings() {
if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined {
AVCaptureDevice.requestAccess(for: .audio) { _ in
DispatchQueue.main.async {
self.buildMenu()
}
}
} else {
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!
NSWorkspace.shared.open(url)
}
}
@objc private func openAccessibilitySettings() { @objc private func openAccessibilitySettings() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
AXIsProcessTrustedWithOptions(options)
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
} }

Loading…
Cancel
Save