From 884b8598758f7e29cb729e9acdbc8c3cc782cd9a Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Tue, 15 Sep 2026 17:12:20 +0000 Subject: [PATCH] feat: hardware microphone key remap, full English menu, adaptive menu bar icon, and permission status items --- src/HotkeyManager.swift | 64 +++++++++++++++++------------------ src/SonioxSettings.swift | 43 +++++++++++++++++++++++ src/StatusBarController.swift | 27 +++++++++++++-- 3 files changed, 100 insertions(+), 34 deletions(-) diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift index d74a098..1807e67 100644 --- a/src/HotkeyManager.swift +++ b/src/HotkeyManager.swift @@ -17,10 +17,10 @@ public enum DictationMode: String, CaseIterable { } public enum HotkeyPreset: String, CaseIterable { + case micKey = "Microphone Key (🎙️ / F5)" case option = "Option (Hold ⌥)" case doubleTapFn = "Double-Tap Fn (Globe 🌐)" case holdFn = "Hold Fn (Globe 🌐)" - case f5 = "F5 (Fn + F5)" case controlSpace = "Control + Space" case capsLock = "Caps Lock" case optionSpace = "Option + Space" @@ -29,6 +29,8 @@ public enum HotkeyPreset: String, CaseIterable { public var keyCode: UInt32 { switch self { + case .micKey: + return UInt32(kVK_F5) // 96 case .controlSpace, .optionSpace, .cmdShiftSpace: return UInt32(kVK_Space) // 49 case .option: @@ -39,8 +41,6 @@ public enum HotkeyPreset: String, CaseIterable { return UInt32(kVK_CapsLock) // 57 case .f8: return UInt32(kVK_F8) - case .f5: - return UInt32(kVK_F5) } } @@ -52,7 +52,7 @@ public enum HotkeyPreset: String, CaseIterable { return UInt32(optionKey) case .cmdShiftSpace: return UInt32(cmdKey | shiftKey) - case .option, .doubleTapFn, .holdFn, .capsLock, .f8, .f5: + case .micKey, .option, .doubleTapFn, .holdFn, .capsLock, .f8: return 0 } } @@ -79,8 +79,8 @@ public final class HotkeyManager { 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.micKey.rawValue + return HotkeyPreset(rawValue: val) ?? .micKey } set { 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)'") // 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 = [ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), 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() // 3. Fallback NSEvent monitor @@ -154,7 +154,7 @@ public final class HotkeyManager { private func triggerAction() { let now = Date().timeIntervalSince1970 - guard (now - lastTriggerTime) > 0.30 else { return } + guard (now - lastTriggerTime) > 0.25 else { return } lastTriggerTime = now DispatchQueue.main.async { [weak self] in self?.onHotkeyPressed?() @@ -172,13 +172,23 @@ public final class HotkeyManager { let flags = event.flags.rawValue 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 { - manager.triggerAction() - return nil // Swallow event + // 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() + } + 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 - manager.triggerAction() - } - return nil - } else if type == .keyUp { - manager.isF5PhysicallyDown = false - if manager.currentMode == .pushToTalk { - DispatchQueue.main.async { - manager.onHotkeyReleased?() - } - } + // 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() return nil } } diff --git a/src/SonioxSettings.swift b/src/SonioxSettings.swift index b0dffbb..128087e 100644 --- a/src/SonioxSettings.swift +++ b/src/SonioxSettings.swift @@ -39,6 +39,10 @@ public final class SonioxSettings { private let defaults = UserDefaults.standard + private init() { + ensureHardwareKeyRemapping() + } + public var language: RecognitionLanguage { get { let val = defaults.string(forKey: "SonioxLanguage") ?? RecognitionLanguage.multi.rawValue @@ -99,10 +103,49 @@ public final class SonioxSettings { defaults.removeObject(forKey: "SonioxPlaySounds") defaults.removeObject(forKey: "SonioxLaunchAtLogin") updateLaunchAtLogin(enabled: false) + ensureHardwareKeyRemapping() HotkeyManager.shared.registerHotkeys() 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 = """ + + + + + Label + com.soniox.keymapping + ProgramArguments + + /usr/bin/hidutil + property + --set + {"UserKeyMapping":[{"HIDKeyboardModifierMappingSrc":3221225679,"HIDKeyboardModifierMappingDst":1879048254},{"HIDKeyboardModifierMappingSrc":4294967451,"HIDKeyboardModifierMappingDst":1879048254}]} + + RunAtLoad + + + + """ + try? plistContent.write(to: plistURL, atomically: true, encoding: .utf8) + } + private func updateLaunchAtLogin(enabled: Bool) { let launchAgentDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents") let plistURL = launchAgentDir.appendingPathComponent("com.soniox.voice.plist") diff --git a/src/StatusBarController.swift b/src/StatusBarController.swift index 4810e7e..59509c7 100644 --- a/src/StatusBarController.swift +++ b/src/StatusBarController.swift @@ -1,4 +1,6 @@ import Cocoa +import AVFoundation +import ApplicationServices public final class StatusBarController { private var statusItem: NSStatusItem? @@ -128,9 +130,15 @@ public final class StatusBarController { advancedMenuItem.submenu = advancedMenu 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 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: "") axItem.target = self menu.addItem(axItem) @@ -210,7 +218,22 @@ public final class StatusBarController { 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() { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + AXIsProcessTrustedWithOptions(options) let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! NSWorkspace.shared.open(url) }