import Cocoa import AVFoundation import ApplicationServices public final class StatusBarController: NSObject, NSMenuDelegate { private var statusItem: NSStatusItem? public var onToggleRecording: (() -> Void)? private let dateFormatter: DateFormatter = { let df = DateFormatter() df.dateFormat = "HH:mm:ss" return df }() public override init() { super.init() setupStatusItem() } private func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) updateIcon(state: .idle) buildMenu() } public enum State { case idle case recording case transcribing } public func updateIcon(state: State) { guard let button = statusItem?.button else { return } switch state { case .idle: 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.contentTintColor = nil button.toolTip = "Soniox Voice (Ready)" case .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 = "Recording speech..." case .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 = "Transcribing with Soniox..." } } public func buildMenu(isRecording: Bool = false) { let menu = NSMenu() menu.autoenablesItems = false menu.delegate = self // 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. Dictation Mode Submenu let modeMenu = NSMenu() for mode in DictationMode.allCases { let item = NSMenuItem(title: mode.localizedTitle, action: #selector(selectModeAction(_:)), keyEquivalent: "") item.target = self item.representedObject = mode item.state = (HotkeyManager.shared.currentMode == mode) ? .on : .off modeMenu.addItem(item) } let modeMenuItem = NSMenuItem(title: "⚙️ Dictation Mode", action: nil, keyEquivalent: "") modeMenuItem.submenu = modeMenu menu.addItem(modeMenuItem) // 3. Hotkey Preset Submenu let hotkeyMenu = NSMenu() for preset in HotkeyPreset.allCases { let item = NSMenuItem(title: preset.rawValue, action: #selector(selectHotkeyAction(_:)), keyEquivalent: "") item.target = self item.representedObject = preset item.state = (HotkeyManager.shared.currentPreset == preset) ? .on : .off hotkeyMenu.addItem(item) } 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) // 5. Microphone Input Device Submenu let micDeviceMenu = NSMenu() let availableDevices = AudioRecorder.availableAudioDevices() let preferredID = SonioxSettings.shared.preferredAudioDeviceID // Default device item let defaultItem = NSMenuItem(title: "System Default Microphone", action: #selector(selectAudioDeviceAction(_:)), keyEquivalent: "") defaultItem.target = self defaultItem.representedObject = nil as String? defaultItem.state = (preferredID == nil) ? .on : .off micDeviceMenu.addItem(defaultItem) if !availableDevices.isEmpty { micDeviceMenu.addItem(NSMenuItem.separator()) for device in availableDevices { let item = NSMenuItem(title: device.localizedName, action: #selector(selectAudioDeviceAction(_:)), keyEquivalent: "") item.target = self item.representedObject = device.uniqueID item.state = (preferredID == device.uniqueID) ? .on : .off micDeviceMenu.addItem(item) } } let micDeviceMenuItem = NSMenuItem(title: "🎙️ Microphone Input", action: nil, keyEquivalent: "") micDeviceMenuItem.submenu = micDeviceMenu menu.addItem(micDeviceMenuItem) // 6. Number Formatting Submenu let numberMenu = NSMenu() for option in NumberFormatOption.allCases { let item = NSMenuItem(title: option.displayName, action: #selector(selectNumberFormatAction(_:)), keyEquivalent: "") item.target = self item.representedObject = option item.state = (SonioxSettings.shared.numberFormat == option) ? .on : .off numberMenu.addItem(item) } let numberMenuItem = NSMenuItem(title: "🔢 Number Formatting", action: nil, keyEquivalent: "") numberMenuItem.submenu = numberMenu menu.addItem(numberMenuItem) menu.addItem(NSMenuItem.separator()) // 7. History Submenu (Last 500 entries) let historyMenu = buildHistorySubmenu() let historyMenuItem = NSMenuItem(title: "🕒 History (\(HistoryManager.shared.totalCount()) of 500)", action: nil, keyEquivalent: "") historyMenuItem.submenu = historyMenu menu.addItem(historyMenuItem) // 8. Advanced Options Submenu let advancedMenu = NSMenu() let slashCmdItem = NSMenuItem(title: "⚡️ Executive Voice Slash Commands", action: #selector(toggleVoiceCommandsAction(_:)), keyEquivalent: "") slashCmdItem.target = self slashCmdItem.state = SonioxSettings.shared.voiceCommands ? .on : .off advancedMenu.addItem(slashCmdItem) let punctItem = NSMenuItem(title: "🗣️ Voice Punctuation Commands", action: #selector(togglePunctuationAction(_:)), keyEquivalent: "") punctItem.target = self punctItem.state = SonioxSettings.shared.voicePunctuation ? .on : .off advancedMenu.addItem(punctItem) let vocabItem = NSMenuItem(title: "📚 Domain Vocabulary Enhancement", action: #selector(toggleDomainVocabAction(_:)), keyEquivalent: "") vocabItem.target = self vocabItem.state = SonioxSettings.shared.domainVocabulary ? .on : .off advancedMenu.addItem(vocabItem) 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 = SonioxSettings.shared.playSounds ? .on : .off advancedMenu.addItem(soundItem) advancedMenu.addItem(NSMenuItem.separator()) let editCmdsItem = NSMenuItem(title: "📝 Customize Voice Commands (JSON)...", action: #selector(editCustomCommandsAction), keyEquivalent: "") editCmdsItem.target = self advancedMenu.addItem(editCmdsItem) let reloadCmdsItem = NSMenuItem(title: "🔄 Reload Custom Commands", action: #selector(reloadCustomCommandsAction), keyEquivalent: "") reloadCmdsItem.target = self advancedMenu.addItem(reloadCmdsItem) let cheatSheetItem = NSMenuItem(title: "📖 Voice Commands Cheat Sheet...", action: #selector(openCheatSheetAction), keyEquivalent: "") cheatSheetItem.target = self advancedMenu.addItem(cheatSheetItem) advancedMenu.addItem(NSMenuItem.separator()) let viewLogItem = NSMenuItem(title: "📜 View Voice Commands Log...", action: #selector(viewVoiceLogAction), keyEquivalent: "") viewLogItem.target = self advancedMenu.addItem(viewLogItem) let openFolderItem = NSMenuItem(title: "📁 Open Logs Folder...", action: #selector(openLogFolderAction), keyEquivalent: "") openFolderItem.target = self advancedMenu.addItem(openFolderItem) let clearLogItem = NSMenuItem(title: "🗑️ Clear Voice Commands Log", action: #selector(clearVoiceLogAction), keyEquivalent: "") clearLogItem.target = self advancedMenu.addItem(clearLogItem) advancedMenu.addItem(NSMenuItem.separator()) let resetWindowMemItem = NSMenuItem(title: "🖥️ Reset Window Memory (Default Full-Screen)", action: #selector(resetWindowMemoryAction), keyEquivalent: "") resetWindowMemItem.target = self advancedMenu.addItem(resetWindowMemItem) let advancedMenuItem = NSMenuItem(title: "🛠️ Advanced Options", action: nil, keyEquivalent: "") advancedMenuItem.submenu = advancedMenu menu.addItem(advancedMenuItem) menu.addItem(NSMenuItem.separator()) // 9. 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" : "⚠️ Accessibility: Click to Grant" let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "") axItem.target = self menu.addItem(axItem) 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()) // 10. Reset to Defaults let resetItem = NSMenuItem(title: "🔄 Reset to Recommended Defaults", action: #selector(resetDefaultsAction), keyEquivalent: "") resetItem.target = self menu.addItem(resetItem) // 11. About & Quit let aboutItem = NSMenuItem(title: "ℹ️ About Soniox Voice", action: #selector(aboutAction), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) let quitItem = NSMenuItem(title: "❌ Quit", action: #selector(quitAction), keyEquivalent: "q") quitItem.target = self menu.addItem(quitItem) statusItem?.menu = menu } private func buildHistorySubmenu() -> NSMenu { let historyMenu = NSMenu() let items = HistoryManager.shared.getRecentEntries(limit: 35) if items.isEmpty { let emptyItem = NSMenuItem(title: "No dictation history yet", action: nil, keyEquivalent: "") emptyItem.isEnabled = false historyMenu.addItem(emptyItem) } else { let count = HistoryManager.shared.totalCount() let header = NSMenuItem(title: "— Recent Transcriptions (Total: \(count)) —", action: nil, keyEquivalent: "") header.isEnabled = false historyMenu.addItem(header) historyMenu.addItem(NSMenuItem.separator()) for item in items { let timeStr = dateFormatter.string(from: item.timestamp) let preview = item.text.count > 45 ? String(item.text.prefix(45)) + "…" : item.text let title = "\(timeStr) \(preview)" let menuItem = NSMenuItem(title: title, action: #selector(historyItemClicked(_:)), keyEquivalent: "") menuItem.target = self menuItem.representedObject = item.text menuItem.toolTip = "\(item.text)\n\n(Click to copy & paste)" historyMenu.addItem(menuItem) } historyMenu.addItem(NSMenuItem.separator()) let copyAllItem = NSMenuItem(title: "📋 Copy All History to Clipboard", action: #selector(copyAllHistoryAction), keyEquivalent: "") copyAllItem.target = self historyMenu.addItem(copyAllItem) let clearItem = NSMenuItem(title: "🗑️ Clear History (All \(count) Items)", action: #selector(clearHistoryAction), keyEquivalent: "") clearItem.target = self historyMenu.addItem(clearItem) } return historyMenu } public func menuWillOpen(_ menu: NSMenu) { // Refresh menu dynamically on hover/open to show live history & devices buildMenu() } @objc private func toggleRecordAction() { onToggleRecording?() } @objc private func selectModeAction(_ sender: NSMenuItem) { if let mode = sender.representedObject as? DictationMode { HotkeyManager.shared.currentMode = mode buildMenu() } } @objc private func selectHotkeyAction(_ sender: NSMenuItem) { if let preset = sender.representedObject as? HotkeyPreset { HotkeyManager.shared.currentPreset = preset buildMenu() } } @objc private func selectLanguageAction(_ sender: NSMenuItem) { if let lang = sender.representedObject as? RecognitionLanguage { SonioxSettings.shared.language = lang buildMenu() } } @objc private func selectAudioDeviceAction(_ sender: NSMenuItem) { let deviceID = sender.representedObject as? String SonioxSettings.shared.preferredAudioDeviceID = deviceID buildMenu() } @objc private func selectNumberFormatAction(_ sender: NSMenuItem) { if let format = sender.representedObject as? NumberFormatOption { SonioxSettings.shared.numberFormat = format buildMenu() } } @objc private func historyItemClicked(_ sender: NSMenuItem) { guard let text = sender.representedObject as? String else { return } let pb = NSPasteboard.general pb.clearContents() pb.setString(text, forType: .string) // Instant paste DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { let source = CGEventSource(stateID: .combinedSessionState) let vKeyCode: CGKeyCode = 0x09 if let down = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true), let up = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) { down.flags = .maskCommand up.flags = .maskCommand down.post(tap: .cghidEventTap) up.post(tap: .cghidEventTap) } } } @objc private func copyAllHistoryAction() { let all = HistoryManager.shared.getAllEntries() guard !all.isEmpty else { return } let formatted = all.map { "[\(self.dateFormatter.string(from: $0.timestamp))] \($0.text)" }.joined(separator: "\n") let pb = NSPasteboard.general pb.clearContents() pb.setString(formatted, forType: .string) } @objc private func clearHistoryAction() { HistoryManager.shared.clearAll() buildMenu() } @objc private func toggleVoiceCommandsAction(_ sender: NSMenuItem) { SonioxSettings.shared.voiceCommands.toggle() buildMenu() } @objc private func togglePunctuationAction(_ sender: NSMenuItem) { SonioxSettings.shared.voicePunctuation.toggle() buildMenu() } @objc private func toggleDomainVocabAction(_ sender: NSMenuItem) { SonioxSettings.shared.domainVocabulary.toggle() 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) { SonioxSettings.shared.playSounds.toggle() buildMenu() } @objc private func editCustomCommandsAction() { CustomCommandManager.shared.openConfigFileInEditor() } @objc private func reloadCustomCommandsAction() { CustomCommandManager.shared.loadCommands() buildMenu() let alert = NSAlert() alert.messageText = "Custom Commands Reloaded" alert.informativeText = "Successfully reloaded custom voice commands from custom_commands.json." alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } @objc private func openCheatSheetAction() { let helpText = """ === Soniox Voice Commands Cheat Sheet === [Applications & Windows] • /settings OR /ستینگ -> Open macOS Settings • /antigravity ide OR /آنتی گرویتی آیدی ای -> Open Antigravity IDE • /antigravity OR /آنتی گرویتی -> Open Antigravity • /firefox OR /فایرفاکس -> Open Firefox • /chrome OR /کروم -> Open Google Chrome • /telegram OR /تلگرام -> Open Telegram • /left OR /چپ -> Tile window to Left half • /right OR /راست -> Tile window to Right half • /maximize OR /تمام صفحه -> Maximize window • /center OR /وسط -> Center window on screen • /hide OR /هاید -> Hide frontmost app • /quit OR /ببند -> Quit frontmost app [Browser & App Tabs / Accounts] • /telegram 1..3 OR /تلگرام ۱..۳ -> Open Telegram & Switch to Account 1..3 • /firefox 1..9 OR /فایرفاکس ۱..۹ -> Open Firefox & Switch to Tab 1..9 • /chrome 1..9 OR /کروم ۱..۹ -> Open Chrome & Switch to Tab 1..9 • /safari 1..9 OR /سافاری ۱..۹ -> Open Safari & Switch to Tab 1..9 • /tab 1..9 OR /تب ۱..۹ -> Jump to Tab 1..9 in active app • /new tab OR /تب جدید -> Open new tab (Cmd+T) • /close tab OR /بستن تب -> Close active tab (Cmd+W) • /reopen tab OR /ری اپن تب -> Reopen closed tab (Cmd+Shift+T) • /next tab / /prev tab -> Switch tabs • /reload OR /رفرش -> Reload web page (Cmd+R) • /search [query] OR /سرچ [موضوع] -> Search on Google • /youtube [query] OR /یوتیوب [موضوع] -> Search on YouTube [System Controls] • /volume up OR /صدا زیاد -> Increase volume (+15%) • /volume down OR /صدا کم -> Decrease volume (-15%) • /volume 50 OR /صدا ۵۰ -> Set volume to 50% • /mute OR /میوت -> Toggle sound mute • /lock OR /قفل -> Lock screen • /screenshot OR /اسکرین شات -> Interactive screenshot • /dark mode OR /دارک مود -> Toggle Dark / Light mode [Editing & Clipboard] • /copy OR /کپی -> Copy selection (Cmd+C) • /paste OR /پیست -> Paste (Cmd+V) • /cut OR /کات -> Cut selection (Cmd+X) • /select all OR /انتخاب همه -> Select All (Cmd+A) • /undo OR /آندو -> Undo (Cmd+Z) • /redo OR /ریدو -> Redo (Cmd+Shift+Z) • /save OR /سیو -> Save document (Cmd+S) • /enter OR /اینتر -> Press Return key [Custom Voice Commands] • Customize anytime via 'custom_commands.json' under Advanced Options! """ let alert = NSAlert() alert.messageText = "Soniox Voice Commands Reference" alert.informativeText = helpText alert.alertStyle = .informational alert.addButton(withTitle: "Got it!") alert.runModal() } @objc private func viewVoiceLogAction() { VoiceCommandLogger.shared.openLogInViewer() } @objc private func openLogFolderAction() { VoiceCommandLogger.shared.openLogFolder() } @objc private func clearVoiceLogAction() { VoiceCommandLogger.shared.clearLog() let alert = NSAlert() alert.messageText = "Voice Log Cleared" alert.informativeText = "The voice commands debug log has been cleared." alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } @objc private func resetWindowMemoryAction() { WindowStateManager.shared.resetAllPreferences() let alert = NSAlert() alert.messageText = "Window Memory Reset" alert.informativeText = "All apps will now open in Full Screen by default unless manually exited." alert.alertStyle = .informational alert.addButton(withTitle: "OK") alert.runModal() } @objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) { SonioxSettings.shared.launchAtLogin.toggle() buildMenu() } @objc private func resetDefaultsAction() { SonioxSettings.shared.resetToDefaults() 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) } @objc private func aboutAction() { let alert = NSAlert() 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: "OK") alert.runModal() } @objc private func quitAction() { NSApplication.shared.terminate(nil) } }