From ab4b435331335c0a857808a9adaa790c8f1eb6c8 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Tue, 15 Sep 2026 16:50:46 +0000 Subject: [PATCH] feat: adaptive menu bar icon, fully English UI, double-tap Fn preset, and advanced settings --- src/AppDelegate.swift | 8 +- src/HotkeyManager.swift | 70 ++++++++++++++---- src/SonioxClient.swift | 14 +++- src/SonioxSettings.swift | 135 ++++++++++++++++++++++++++++++++++ src/StatusBarController.swift | 122 ++++++++++++++++++++++-------- 5 files changed, 295 insertions(+), 54 deletions(-) create mode 100644 src/SonioxSettings.swift diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 32d1a64..c718c40 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -80,8 +80,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } private func playSystemSound(name: String) { - let soundsEnabled = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") - if soundsEnabled { + if SonioxSettings.shared.playSounds { NSSound(named: NSSound.Name(name))?.play() } } @@ -172,8 +171,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private func pasteTextToFrontmostApp(text: String) { guard !text.isEmpty else { return } - // 1. Put into system pasteboard with a trailing space - let textToPaste = text + " " + // 1. Put into system pasteboard + let textToPaste = SonioxSettings.shared.addTrailingSpace ? (text + " ") : text let pb = NSPasteboard.general pb.clearContents() pb.setString(textToPaste, forType: .string) @@ -183,7 +182,6 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { print("Accessibility permission NOT granted for auto-paste!") let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary AXIsProcessTrustedWithOptions(options) - HUDOverlayController.shared.show(state: .error(message: "نیاز به تیک Accessibility برای تایپ خودکار")) return } diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift index f39c266..d74a098 100644 --- a/src/HotkeyManager.swift +++ b/src/HotkeyManager.swift @@ -3,21 +3,23 @@ import Carbon import ApplicationServices public enum DictationMode: String, CaseIterable { - case toggle = "toggle" // Press once to start, press again to stop case pushToTalk = "pushToTalk" // Hold to record, release to transcribe + case toggle = "toggle" // Press once to start, press again to stop public var localizedTitle: String { switch self { - case .toggle: - return "فشردن برای شروع / توقف (Toggle)" case .pushToTalk: - return "نگه‌داشتن برای صحبت (Push to Talk)" + return "Push to Talk (Hold Key)" + case .toggle: + return "Toggle (Press to Start / Stop)" } } } public enum HotkeyPreset: String, CaseIterable { - case option = "Option (⌥ نگه‌داشتن)" + 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" @@ -31,6 +33,8 @@ public enum HotkeyPreset: String, CaseIterable { return UInt32(kVK_Space) // 49 case .option: return UInt32(kVK_Option) // 58 + case .doubleTapFn, .holdFn: + return UInt32(kVK_Function) // 63 case .capsLock: return UInt32(kVK_CapsLock) // 57 case .f8: @@ -48,7 +52,7 @@ public enum HotkeyPreset: String, CaseIterable { return UInt32(optionKey) case .cmdShiftSpace: return UInt32(cmdKey | shiftKey) - case .option, .capsLock, .f8, .f5: + case .option, .doubleTapFn, .holdFn, .capsLock, .f8, .f5: return 0 } } @@ -69,6 +73,8 @@ public final class HotkeyManager { private var isOptionPhysicallyDown = false private var isCapsLockPhysicallyDown = false private var isF5PhysicallyDown = false + private var isFnPhysicallyDown = false + private var lastFnPressTime: TimeInterval = 0 private var lastTriggerTime: TimeInterval = 0 public var currentPreset: HotkeyPreset { @@ -101,8 +107,8 @@ public final class HotkeyManager { let preset = currentPreset 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 && preset != .f5 { + // 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 { var eventTypes = [ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)) @@ -139,7 +145,7 @@ public final class HotkeyManager { ) } - // 2. Global Event Tap for high-priority interception (Swallow Control+Space & modifier handling) + // 2. Global Event Tap for high-priority interception (Option, CapsLock, F5, Fn/Globe) setupEventTap() // 3. Fallback NSEvent monitor @@ -148,7 +154,7 @@ public final class HotkeyManager { private func triggerAction() { let now = Date().timeIntervalSince1970 - guard (now - lastTriggerTime) > 0.35 else { return } + guard (now - lastTriggerTime) > 0.30 else { return } lastTriggerTime = now DispatchQueue.main.async { [weak self] in self?.onHotkeyPressed?() @@ -166,20 +172,20 @@ public final class HotkeyManager { let flags = event.flags.rawValue let keyCode = event.getIntegerValueField(.keyboardEventKeycode) - // 1. Control + Space (Swallow keystroke and toggle speech) + // 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 so space is not typed in the active app + return nil // Swallow event } } } // 2. Option Key else if manager.currentPreset == .option { - if keyCode == 58 || keyCode == 61 { + if keyCode == 58 || keyCode == 61 { // 58 = Left Option, 61 = Right Option let isAlt = (flags & CGEventFlags.maskAlternate.rawValue) != 0 if isAlt != manager.isOptionPhysicallyDown { manager.isOptionPhysicallyDown = isAlt @@ -196,7 +202,7 @@ public final class HotkeyManager { // 3. CapsLock Key else if manager.currentPreset == .capsLock { - if keyCode == 57 { + if keyCode == 57 { // 57 = CapsLock if !manager.isCapsLockPhysicallyDown { manager.isCapsLockPhysicallyDown = true manager.triggerAction() @@ -214,7 +220,7 @@ public final class HotkeyManager { // 4. F5 Key (Fn + F5) else if manager.currentPreset == .f5 { - if keyCode == 96 { + if keyCode == 96 { // 96 = kVK_F5 if type == .keyDown { if !manager.isF5PhysicallyDown { manager.isF5PhysicallyDown = true @@ -233,6 +239,40 @@ public final class HotkeyManager { } } + // 5. Fn / Globe Key (Double-Tap or Hold) + else if manager.currentPreset == .doubleTapFn || manager.currentPreset == .holdFn { + if keyCode == 63 { // 63 = kVK_Function (Fn / Globe) + let isFnActive = (flags & CGEventFlags.maskSecondaryFn.rawValue) != 0 + if isFnActive != manager.isFnPhysicallyDown { + manager.isFnPhysicallyDown = isFnActive + + if isFnActive { + let now = Date().timeIntervalSince1970 + let interval = now - manager.lastFnPressTime + manager.lastFnPressTime = now + + if manager.currentPreset == .doubleTapFn { + if interval > 0.05 && interval < 0.45 { + manager.lastFnPressTime = 0 + manager.triggerAction() + return nil + } + } else if manager.currentPreset == .holdFn { + manager.triggerAction() + return nil + } + } else { + if manager.currentPreset == .holdFn && manager.currentMode == .pushToTalk { + DispatchQueue.main.async { + manager.onHotkeyReleased?() + } + return nil + } + } + } + } + } + return Unmanaged.passRetained(event) } diff --git a/src/SonioxClient.swift b/src/SonioxClient.swift index f0451bf..e3f115f 100644 --- a/src/SonioxClient.swift +++ b/src/SonioxClient.swift @@ -298,10 +298,19 @@ public final class SonioxSessionPool { return } - let session = SonioxLiveSession() + let hints = SonioxSettings.shared.language.hints + let session = SonioxLiveSession(languageHints: hints) self.prewarmedSession = session } + public func reconnectAll() { + lock.lock() + prewarmedSession?.cancel() + prewarmedSession = nil + lock.unlock() + prewarmNextSession() + } + public func acquireSession() -> SonioxLiveSession { lock.lock() let session = prewarmedSession @@ -316,6 +325,7 @@ public final class SonioxSessionPool { return session } - return SonioxLiveSession() + let hints = SonioxSettings.shared.language.hints + return SonioxLiveSession(languageHints: hints) } } diff --git a/src/SonioxSettings.swift b/src/SonioxSettings.swift new file mode 100644 index 0000000..b0dffbb --- /dev/null +++ b/src/SonioxSettings.swift @@ -0,0 +1,135 @@ +import Foundation +import Cocoa + +public enum RecognitionLanguage: String, CaseIterable { + case multi = "multi" + case persian = "fa" + case english = "en" + case persianEnglish = "fa_en" + + public var displayName: String { + switch self { + case .multi: + return "Multilingual (Persian, English, Arabic)" + case .persian: + return "Persian Only (فارسی)" + case .english: + return "English Only" + case .persianEnglish: + return "Persian + English" + } + } + + public var hints: [String] { + switch self { + case .multi: + return ["fa", "en", "ar"] + case .persian: + return ["fa"] + case .english: + return ["en"] + case .persianEnglish: + return ["fa", "en"] + } + } +} + +public final class SonioxSettings { + public static let shared = SonioxSettings() + + private let defaults = UserDefaults.standard + + public var language: RecognitionLanguage { + get { + let val = defaults.string(forKey: "SonioxLanguage") ?? RecognitionLanguage.multi.rawValue + return RecognitionLanguage(rawValue: val) ?? .multi + } + set { + defaults.set(newValue.rawValue, forKey: "SonioxLanguage") + SonioxSessionPool.shared.reconnectAll() + } + } + + public var addTrailingSpace: Bool { + get { + if defaults.object(forKey: "SonioxAddTrailingSpace") == nil { return true } + return defaults.bool(forKey: "SonioxAddTrailingSpace") + } + set { + defaults.set(newValue, forKey: "SonioxAddTrailingSpace") + } + } + + public var normalizeText: Bool { + get { + if defaults.object(forKey: "SonioxNormalizeText") == nil { return true } + return defaults.bool(forKey: "SonioxNormalizeText") + } + set { + defaults.set(newValue, forKey: "SonioxNormalizeText") + } + } + + public var playSounds: Bool { + get { + if defaults.object(forKey: "SonioxPlaySounds") == nil { return true } + return defaults.bool(forKey: "SonioxPlaySounds") + } + set { + defaults.set(newValue, forKey: "SonioxPlaySounds") + } + } + + public var launchAtLogin: Bool { + get { + return defaults.bool(forKey: "SonioxLaunchAtLogin") + } + set { + defaults.set(newValue, forKey: "SonioxLaunchAtLogin") + updateLaunchAtLogin(enabled: newValue) + } + } + + public func resetToDefaults() { + defaults.removeObject(forKey: "SonioxHotkeyPreset") + defaults.removeObject(forKey: "SonioxDictationMode") + defaults.removeObject(forKey: "SonioxLanguage") + defaults.removeObject(forKey: "SonioxAddTrailingSpace") + defaults.removeObject(forKey: "SonioxNormalizeText") + defaults.removeObject(forKey: "SonioxPlaySounds") + defaults.removeObject(forKey: "SonioxLaunchAtLogin") + updateLaunchAtLogin(enabled: false) + HotkeyManager.shared.registerHotkeys() + SonioxSessionPool.shared.reconnectAll() + } + + private func updateLaunchAtLogin(enabled: Bool) { + let launchAgentDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents") + let plistURL = launchAgentDir.appendingPathComponent("com.soniox.voice.plist") + + if enabled { + try? FileManager.default.createDirectory(at: launchAgentDir, withIntermediateDirectories: true) + let plistContent = """ + + + + + Label + com.soniox.voice + ProgramArguments + + /Applications/Soniox Voice.app/Contents/MacOS/SonioxVoice + + RunAtLoad + + KeepAlive + + + + """ + try? plistContent.write(to: plistURL, atomically: true, encoding: .utf8) + } else { + try? FileManager.default.removeItem(at: plistURL) + } + } +} diff --git a/src/StatusBarController.swift b/src/StatusBarController.swift index 29457fc..4810e7e 100644 --- a/src/StatusBarController.swift +++ b/src/StatusBarController.swift @@ -25,25 +25,31 @@ public final class StatusBarController { switch state { case .idle: - if let image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Soniox Voice") { + let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .regular) + if let image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Soniox Voice")?.withSymbolConfiguration(config) { image.isTemplate = true button.image = image } - button.toolTip = "Soniox Voice (آماده)" + button.contentTintColor = nil + button.toolTip = "Soniox Voice (Ready)" + case .recording: - if let image = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording") { + let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold) + if let image = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording")?.withSymbolConfiguration(config) { image.isTemplate = false button.image = image button.contentTintColor = NSColor.systemRed } - button.toolTip = "در حال ضبط صدا..." + button.toolTip = "Recording speech..." + case .transcribing: - if let image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Transcribing") { + let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold) + if let image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Transcribing")?.withSymbolConfiguration(config) { image.isTemplate = false button.image = image button.contentTintColor = NSColor.systemOrange } - button.toolTip = "در حال تبدیل به متن..." + button.toolTip = "Transcribing with Soniox..." } } @@ -51,15 +57,15 @@ public final class StatusBarController { let menu = NSMenu() menu.autoenablesItems = false - // 1. Record Action - let recordTitle = isRecording ? "⏹️ توقف ضبط و درج متن" : "🎙️ شروع ضبط صدا (\(HotkeyManager.shared.currentPreset.rawValue))" + // 1. Primary Action: Start / Stop Recording + let recordTitle = isRecording ? "⏹️ Stop Recording & Insert Text" : "🎙️ Start Recording (\(HotkeyManager.shared.currentPreset.rawValue))" let recordItem = NSMenuItem(title: recordTitle, action: #selector(toggleRecordAction), keyEquivalent: "") recordItem.target = self menu.addItem(recordItem) menu.addItem(NSMenuItem.separator()) - // 2. Mode Submenu + // 2. Dictation Mode Submenu let modeMenu = NSMenu() for mode in DictationMode.allCases { let item = NSMenuItem(title: mode.localizedTitle, action: #selector(selectModeAction(_:)), keyEquivalent: "") @@ -68,11 +74,11 @@ public final class StatusBarController { item.state = (HotkeyManager.shared.currentMode == mode) ? .on : .off modeMenu.addItem(item) } - let modeMenuItem = NSMenuItem(title: "⚙️ حالت کارکرد", action: nil, keyEquivalent: "") + let modeMenuItem = NSMenuItem(title: "⚙️ Dictation Mode", action: nil, keyEquivalent: "") modeMenuItem.submenu = modeMenu menu.addItem(modeMenuItem) - // 3. Hotkey Submenu + // 3. Hotkey Preset Submenu let hotkeyMenu = NSMenu() for preset in HotkeyPreset.allCases { let item = NSMenuItem(title: preset.rawValue, action: #selector(selectHotkeyAction(_:)), keyEquivalent: "") @@ -81,41 +87,73 @@ public final class StatusBarController { item.state = (HotkeyManager.shared.currentPreset == preset) ? .on : .off hotkeyMenu.addItem(item) } - let hotkeyMenuItem = NSMenuItem(title: "⌨️ کلید میانبر (Hotkey)", action: nil, keyEquivalent: "") + let hotkeyMenuItem = NSMenuItem(title: "⌨️ Shortcut Key", action: nil, keyEquivalent: "") hotkeyMenuItem.submenu = hotkeyMenu menu.addItem(hotkeyMenuItem) + // 4. Recognition Language Submenu + let langMenu = NSMenu() + for lang in RecognitionLanguage.allCases { + let item = NSMenuItem(title: lang.displayName, action: #selector(selectLanguageAction(_:)), keyEquivalent: "") + item.target = self + item.representedObject = lang + item.state = (SonioxSettings.shared.language == lang) ? .on : .off + langMenu.addItem(item) + } + let langMenuItem = NSMenuItem(title: "🌐 Recognition Language", action: nil, keyEquivalent: "") + langMenuItem.submenu = langMenu + menu.addItem(langMenuItem) + menu.addItem(NSMenuItem.separator()) - // 4. Sound Effects Toggle - let soundsEnabled = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") - let soundItem = NSMenuItem(title: "🔊 پخش افکت صوتی", action: #selector(toggleSoundsAction(_:)), keyEquivalent: "") + // 5. Advanced Insertion Settings Submenu + let advancedMenu = NSMenu() + + let trailingSpaceItem = NSMenuItem(title: "␣ Add Trailing Space", action: #selector(toggleTrailingSpaceAction(_:)), keyEquivalent: "") + trailingSpaceItem.target = self + trailingSpaceItem.state = SonioxSettings.shared.addTrailingSpace ? .on : .off + advancedMenu.addItem(trailingSpaceItem) + + let normalizeItem = NSMenuItem(title: "✨ Persian Text Normalization", action: #selector(toggleNormalizeAction(_:)), keyEquivalent: "") + normalizeItem.target = self + normalizeItem.state = SonioxSettings.shared.normalizeText ? .on : .off + advancedMenu.addItem(normalizeItem) + + let soundItem = NSMenuItem(title: "🔊 Sound Feedback", action: #selector(toggleSoundsAction(_:)), keyEquivalent: "") soundItem.target = self - soundItem.state = soundsEnabled ? .on : .off - menu.addItem(soundItem) + soundItem.state = SonioxSettings.shared.playSounds ? .on : .off + advancedMenu.addItem(soundItem) - // 5. Accessibility Permission Check + let advancedMenuItem = NSMenuItem(title: "🛠️ Advanced Options", action: nil, keyEquivalent: "") + advancedMenuItem.submenu = advancedMenu + menu.addItem(advancedMenuItem) + + // 6. System Permissions & Launch at Login let isAxTrusted = AXIsProcessTrusted() - let axTitle = isAxTrusted ? "✅ دسترسی Accessibility فعال است" : "🔑 اعطای دسترسی Accessibility..." + let axTitle = isAxTrusted ? "✅ Accessibility Granted" : "🔑 Grant Accessibility Access..." let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "") axItem.target = self menu.addItem(axItem) - // 6. Launch at Login - let launchLogin = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin") - let launchItem = NSMenuItem(title: "🚀 اجرا هنگام بالا آمدن سیستم", action: #selector(toggleLaunchAtLoginAction(_:)), keyEquivalent: "") + let launchLogin = SonioxSettings.shared.launchAtLogin + let launchItem = NSMenuItem(title: "🚀 Launch at Login", action: #selector(toggleLaunchAtLoginAction(_:)), keyEquivalent: "") launchItem.target = self launchItem.state = launchLogin ? .on : .off menu.addItem(launchItem) menu.addItem(NSMenuItem.separator()) - // 7. About & Quit - let aboutItem = NSMenuItem(title: "ℹ️ درباره Soniox Voice", action: #selector(aboutAction), keyEquivalent: "") + // 7. Reset to Defaults + let resetItem = NSMenuItem(title: "🔄 Reset to Recommended Defaults", action: #selector(resetDefaultsAction), keyEquivalent: "") + resetItem.target = self + menu.addItem(resetItem) + + // 8. About & Quit + let aboutItem = NSMenuItem(title: "ℹ️ About Soniox Voice", action: #selector(aboutAction), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) - let quitItem = NSMenuItem(title: "❌ خروج", action: #selector(quitAction), keyEquivalent: "q") + let quitItem = NSMenuItem(title: "❌ Quit", action: #selector(quitAction), keyEquivalent: "q") quitItem.target = self menu.addItem(quitItem) @@ -140,15 +178,35 @@ public final class StatusBarController { } } + @objc private func selectLanguageAction(_ sender: NSMenuItem) { + if let lang = sender.representedObject as? RecognitionLanguage { + SonioxSettings.shared.language = lang + buildMenu() + } + } + + @objc private func toggleTrailingSpaceAction(_ sender: NSMenuItem) { + SonioxSettings.shared.addTrailingSpace.toggle() + buildMenu() + } + + @objc private func toggleNormalizeAction(_ sender: NSMenuItem) { + SonioxSettings.shared.normalizeText.toggle() + buildMenu() + } + @objc private func toggleSoundsAction(_ sender: NSMenuItem) { - let current = UserDefaults.standard.bool(forKey: "SonioxPlaySounds") - UserDefaults.standard.set(!current, forKey: "SonioxPlaySounds") + SonioxSettings.shared.playSounds.toggle() buildMenu() } @objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) { - let current = UserDefaults.standard.bool(forKey: "SonioxLaunchAtLogin") - UserDefaults.standard.set(!current, forKey: "SonioxLaunchAtLogin") + SonioxSettings.shared.launchAtLogin.toggle() + buildMenu() + } + + @objc private func resetDefaultsAction() { + SonioxSettings.shared.resetToDefaults() buildMenu() } @@ -159,10 +217,10 @@ public final class StatusBarController { @objc private func aboutAction() { let alert = NSAlert() - alert.messageText = "Soniox Voice v1.0" - alert.informativeText = "تبدیل بلادرنگ گفتار به متن فارسی و انگلیسی با موتور ابری فوق سریع Soniox.\n\nتوسعه یافته برای macOS." + alert.messageText = "Soniox Voice for macOS" + alert.informativeText = "High-accuracy, real-time speech-to-text dictation powered by Soniox AI.\n\nOptimized for macOS Apple Silicon." alert.alertStyle = .informational - alert.addButton(withTitle: "باشه") + alert.addButton(withTitle: "OK") alert.runModal() }