Skip to main content
Glama

Whispera

by sapoepsilon
GlobalShortcutManager.swift30.3 kB
import Foundation import Cocoa import ApplicationServices import SwiftUI class GlobalShortcutManager: ObservableObject { private var globalMonitor: Any? private var localMonitor: Any? private var fileSelectionGlobalMonitor: Any? private var fileSelectionLocalMonitor: Any? private var audioManager: AudioManager? private var fileTranscriptionManager: FileTranscriptionManager? private var networkDownloader: NetworkFileDownloader? private var queueManager: TranscriptionQueueManager? private var isProcessingFileOperation = false private let logger = AppLogger.shared.general var currentShortcut: String = UserDefaults.standard.string(forKey: "globalShortcut") ?? "⌃A" var fileSelectionShortcut: String = UserDefaults.standard.string(forKey: "fileSelectionShortcut") ?? "⌃F" // MARK: - Settings private var autoDeleteDownloadedFiles: Bool { UserDefaults.standard.bool(forKey: "autoDeleteDownloadedFiles") } init() { setupShortcut() NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in let newShortcut = UserDefaults.standard.string(forKey: "globalShortcut") ?? "⌃A" let newFileShortcut = UserDefaults.standard.string(forKey: "fileSelectionShortcut") ?? "⌃F" if newShortcut != self?.currentShortcut { self?.logger.info("🔄 Text shortcut changed: \(self?.currentShortcut ?? "nil") → \(newShortcut)") self?.currentShortcut = newShortcut self?.setupShortcut() } if newFileShortcut != self?.fileSelectionShortcut { self?.logger.info("🔄 File selection shortcut changed: \(self?.fileSelectionShortcut ?? "nil") → \(newFileShortcut)") self?.fileSelectionShortcut = newFileShortcut self?.setupShortcut() } } } func setAudioManager(_ manager: AudioManager) { self.audioManager = manager logger.info("🔗 AudioManager set, checking accessibility status...") checkAccessibilityStatus() } func setFileTranscriptionManager(_ manager: FileTranscriptionManager) { self.fileTranscriptionManager = manager logger.info("🔗 FileTranscriptionManager set") } func setNetworkDownloader(_ downloader: NetworkFileDownloader) { self.networkDownloader = downloader logger.info("🔗 NetworkFileDownloader set") } func setQueueManager(_ manager: TranscriptionQueueManager) { self.queueManager = manager logger.info("🔗 TranscriptionQueueManager set") } func checkAccessibilityStatus() { let hasPermissions = AXIsProcessTrusted() logger.info("🔐 Current accessibility permissions: \(hasPermissions)") logger.info("🎯 Text shortcut: \(currentShortcut)") logger.info("📁 File selection shortcut: \(fileSelectionShortcut)") logger.info("🎛️ Global monitors active - Text: \(globalMonitor != nil), File: \(fileSelectionGlobalMonitor != nil)") if !hasPermissions { logger.error("⚠️ PROBLEM: No accessibility permissions - shortcuts will NOT work") logger.error("💡 Go to System Settings > Privacy & Security > Accessibility") logger.error("💡 Add Whispera to the list and enable it") } else if globalMonitor == nil || fileSelectionGlobalMonitor == nil { logger.error("⚠️ PROBLEM: Some global monitors not set up despite having permissions") setupShortcut() } } private func setupShortcut() { logger.info("🔧 setupShortcut() called") // Remove existing monitors if let monitor = globalMonitor { NSEvent.removeMonitor(monitor) self.globalMonitor = nil logger.info("🗑️ Removed old text global monitor") } if let monitor = localMonitor { NSEvent.removeMonitor(monitor) self.localMonitor = nil logger.info("🗑️ Removed old text local monitor") } if let monitor = fileSelectionGlobalMonitor { NSEvent.removeMonitor(monitor) self.fileSelectionGlobalMonitor = nil logger.info("🗑️ Removed old file selection global monitor") } if let monitor = fileSelectionLocalMonitor { NSEvent.removeMonitor(monitor) self.fileSelectionLocalMonitor = nil logger.info("🗑️ Removed old file selection local monitor") } // Setup text shortcut let (textModifiers, textKeyCode) = parseShortcut(currentShortcut) logger.info("🎹 Setting up text shortcut for \(currentShortcut) (keyCode: \(textKeyCode), modifiers: \(textModifiers.rawValue))") // Setup file selection shortcut let (fileModifiers, fileKeyCode) = parseShortcut(fileSelectionShortcut) logger.info("📁 Setting up file selection shortcut for \(fileSelectionShortcut) (keyCode: \(fileKeyCode), modifiers: \(fileModifiers.rawValue))") // Set up global monitors (works when other apps are focused) logger.info("🌍 Installing global monitors...") globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in if self?.matchesShortcut(event: event, expectedModifiers: textModifiers, expectedKeyCode: textKeyCode) == true { self?.logger.info("🎯 Global text shortcut detected!") self?.handleTextHotKey() } else if self?.matchesShortcut(event: event, expectedModifiers: fileModifiers, expectedKeyCode: fileKeyCode) == true { self?.logger.info("📁 Global file selection shortcut detected!") self?.handleFileSelectionHotKey() } } fileSelectionGlobalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in if self?.matchesShortcut(event: event, expectedModifiers: fileModifiers, expectedKeyCode: fileKeyCode) == true { self?.logger.info("📁 Global file selection shortcut detected (dedicated monitor)!") self?.handleFileSelectionHotKey() } } // Also set up local monitors as fallback (works when app is focused) logger.info("🏠 Installing local monitors as fallback...") localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in if self?.matchesShortcut(event: event, expectedModifiers: textModifiers, expectedKeyCode: textKeyCode) == true { self?.logger.info("🎯 Local text shortcut detected!") self?.handleTextHotKey() return nil // Consume the event } else if self?.matchesShortcut(event: event, expectedModifiers: fileModifiers, expectedKeyCode: fileKeyCode) == true { self?.logger.info("📁 Local file selection shortcut detected!") self?.handleFileSelectionHotKey() return nil // Consume the event } return event } fileSelectionLocalMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in if self?.matchesShortcut(event: event, expectedModifiers: fileModifiers, expectedKeyCode: fileKeyCode) == true { self?.logger.info("📁 Local file selection shortcut detected (dedicated monitor)!") self?.handleFileSelectionHotKey() return nil // Consume the event } return event } logger.info("✅ Monitors installed - Text Global: \(globalMonitor != nil), Text Local: \(localMonitor != nil)") logger.info("✅ File monitors installed - File Global: \(fileSelectionGlobalMonitor != nil), File Local: \(fileSelectionLocalMonitor != nil)") } private func parseShortcut(_ shortcut: String) -> (NSEvent.ModifierFlags, UInt16) { var modifiers: NSEvent.ModifierFlags = [] var keyChar = "" logger.debug("🔍 Parsing shortcut: '\(shortcut)'") if shortcut.contains("⌘") { modifiers.insert(.command) } if shortcut.contains("⌥") { modifiers.insert(.option) } if shortcut.contains("⌃") { modifiers.insert(.control) } if shortcut.contains("⇧") { modifiers.insert(.shift) } // Extract the key character (everything after modifiers) let modifierSymbols = "⌘⌥⌃⇧" var remainingShortcut = shortcut // Remove all modifier symbols from the beginning for symbol in modifierSymbols { remainingShortcut = remainingShortcut.replacingOccurrences(of: String(symbol), with: "") } keyChar = remainingShortcut.trimmingCharacters(in: .whitespaces) let keyCode = keyCodeForCharacter(keyChar.lowercased()) logger.debug("🔍 Parsed: keyChar='\(keyChar)', keyCode=\(keyCode), modifiers=\(modifiers.rawValue)") return (modifiers, keyCode) } private func keyCodeForCharacter(_ char: String) -> UInt16 { // Map common characters and special keys to key codes switch char.lowercased() { // Letters case "a": return 0 case "b": return 11 case "c": return 8 case "d": return 2 case "e": return 14 case "f": return 3 case "g": return 5 case "h": return 4 case "i": return 34 case "j": return 38 case "k": return 40 case "l": return 37 case "m": return 46 case "n": return 45 case "o": return 31 case "p": return 35 case "q": return 12 case "r": return 15 case "s": return 1 case "t": return 17 case "u": return 32 case "v": return 9 case "w": return 13 case "x": return 7 case "y": return 16 case "z": return 6 // Numbers case "0": return 29 case "1": return 18 case "2": return 19 case "3": return 20 case "4": return 21 case "5": return 23 case "6": return 22 case "7": return 26 case "8": return 28 case "9": return 25 // Function keys case "f1": return 122 case "f2": return 120 case "f3": return 99 case "f4": return 118 case "f5": return 96 case "f6": return 97 case "f7": return 98 case "f8": return 100 case "f9": return 101 case "f10": return 109 case "f11": return 103 case "f12": return 111 case "f13": return 105 case "f14": return 107 case "f15": return 113 case "f16": return 106 case "f17": return 64 case "f18": return 79 case "f19": return 80 case "f20": return 90 // Special keys case "space", " ": return 49 case "return", "enter", "↩": return 36 case "tab", "⇥": return 48 case "delete", "⌫": return 51 case "escape", "esc", "⎋": return 53 case "home", "↖": return 115 case "end", "↘": return 119 case "pageup", "⇞": return 116 case "pagedown", "⇟": return 121 case "up", "↑": return 126 case "down", "↓": return 125 case "left", "←": return 123 case "right", "→": return 124 case "clear", "⌧": return 71 case "help", "?⃝": return 114 // Punctuation case "-": return 27 case "=": return 24 case "[": return 33 case "]": return 30 case "\\": return 42 case ";": return 41 case "'": return 39 case ",": return 43 case ".": return 47 case "/": return 44 case "`": return 50 // Globe/Fn key (on newer Macs) case "globe", "fn", "🌐": return 63 default: return 15 // Default to 'R' key } } private func matchesShortcut(event: NSEvent, expectedModifiers: NSEvent.ModifierFlags, expectedKeyCode: UInt16) -> Bool { return event.modifierFlags.intersection([.command, .option, .control, .shift]) == expectedModifiers && event.keyCode == expectedKeyCode } func requestAccessibilityPermissions() { logger.info("🔐 Requesting accessibility permissions...") let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] let accessEnabled = AXIsProcessTrustedWithOptions(options) if accessEnabled { logger.info("✅ Accessibility permissions granted, setting up shortcut") setupShortcut() } else { logger.info("⏳ Waiting for accessibility permissions...") logger.info("📱 Please go to System Settings > Privacy & Security > Accessibility and enable Whispera") logger.info("💡 Global shortcuts will NOT work until accessibility permissions are granted") // Check again every 3 seconds for up to 30 seconds var checkCount = 0 let maxChecks = 10 func checkPermissions() { checkCount += 1 if AXIsProcessTrusted() { self.logger.info("✅ Accessibility permissions now granted! Setting up global shortcuts...") self.setupShortcut() } else if checkCount < maxChecks { self.logger.info("⏳ Still waiting for accessibility permissions... (\(checkCount)/\(maxChecks))") DispatchQueue.main.asyncAfter(deadline: .now() + 3) { checkPermissions() } } else { self.logger.error("⚠️ Accessibility permissions still not granted. Global shortcuts disabled.") self.logger.error("💡 You can grant permissions later in System Settings > Privacy & Security > Accessibility") } } DispatchQueue.main.asyncAfter(deadline: .now() + 3) { checkPermissions() } } } private func handleTextHotKey() { Task { @MainActor in // Check if haptic feedback is enabled if UserDefaults.standard.bool(forKey: "shortcutHapticFeedback") { NSHapticFeedbackManager.defaultPerformer .perform(.levelChange, performanceTime: .now) } audioManager?.toggleRecording() } } private func handleFileSelectionHotKey() { Task { @MainActor in logger.info("📁 File selection shortcut activated") // Prevent duplicate processing guard !isProcessingFileOperation else { logger.info("⚠️ File operation already in progress, ignoring shortcut") return } isProcessingFileOperation = true defer { isProcessingFileOperation = false } // Check if haptic feedback is enabled if UserDefaults.standard.bool(forKey: "shortcutHapticFeedback") { NSHapticFeedbackManager.defaultPerformer .perform(.levelChange, performanceTime: .now) } // First, try to get selected files from Finder let finderSelection = await getFinderSelectedFiles() if !finderSelection.isEmpty { logger.info("📂 Found \(finderSelection.count) selected files in Finder") await handleSelectedFiles(finderSelection) return } // Check if there's a URL in the clipboard let pasteboard = NSPasteboard.general if let clipboardString = pasteboard.string(forType: .string), let url = URL(string: clipboardString), url.scheme == "http" || url.scheme == "https" { logger.info("🔗 Found URL in clipboard: \(clipboardString)") await handleClipboardURL(url) } else { // Open file selection dialog as fallback await openFileSelectionDialog() } } } private func getFinderSelectedFiles() async -> [URL] { let script = """ tell application "Finder" set selectedItems to selection set filePaths to {} repeat with selectedItem in selectedItems if class of selectedItem is document file then set end of filePaths to POSIX path of (selectedItem as alias) end if end repeat return filePaths end tell """ let appleScript = NSAppleScript(source: script) var error: NSDictionary? let result = appleScript?.executeAndReturnError(&error) if let error = error { let errorCode = error["NSAppleScriptErrorNumber"] as? Int ?? 0 switch errorCode { case -1751: logger.info("ℹ️ AppleScript: User canceled or no files selected in Finder") case -1743: logger.error("⚠️ AppleScript: Finder is not running or accessible") case -1700: logger.error("⚠️ AppleScript: Access denied to Finder") default: logger.error("⚠️ AppleScript error: \(error)") } return [] } if let result = result { // Handle the result - it might be a list or a single value let paths = extractPathsFromAppleScriptResult(result) let urls = paths.compactMap { path -> URL? in return URL(fileURLWithPath: path) } return urls.filter { url in let fileExtension = url.pathExtension.lowercased() return SupportedFileTypes.allFormats.contains(fileExtension) } } return [] } private func extractPathsFromAppleScriptResult(_ result: NSAppleEventDescriptor) -> [String] { var paths: [String] = [] // Check if it's a list if result.descriptorType == typeAEList { let listSize = result.numberOfItems // Guard against empty lists to avoid Range error guard listSize > 0 else { return paths } for i in 1...listSize { if let item = result.atIndex(i), let path = item.stringValue { paths.append(path) } } } else if let singlePath = result.stringValue { // Single item paths.append(singlePath) } return paths } @MainActor private func handleSelectedFiles(_ urls: [URL]) async { logger.info("🎵 Adding \(urls.count) selected audio files to transcription queue") guard let queueManager = queueManager else { logger.error("❌ TranscriptionQueueManager not available, falling back to direct processing") await handleSelectedFilesDirectly(urls) return } // Add all files to the queue queueManager.addFiles(urls) logger.info("✅ Added \(urls.count) files to transcription queue") // Show a notification that files were added to queue let notification = NSUserNotification() notification.title = "Files Added to Queue" notification.subtitle = "\(urls.count) file(s) queued for transcription" notification.informativeText = urls.map { $0.lastPathComponent }.joined(separator: ", ") NSUserNotificationCenter.default.deliver(notification) } @MainActor private func handleSelectedFilesDirectly(_ urls: [URL]) async { logger.info("🎵 Processing \(urls.count) selected audio files directly") guard let fileManager = fileTranscriptionManager else { logger.error("❌ FileTranscriptionManager not available") return } // For now, we'll process the first file (can be enhanced for multiple files) guard let firstFile = urls.first else { return } do { logger.info("📝 Starting transcription for: \(firstFile.lastPathComponent)") let result = try await fileManager.transcribeFile(at: firstFile) // Show the result in a notification or window await showTranscriptionResult(for: firstFile.lastPathComponent, result: result) } catch { logger.error("❌ Transcription failed: \(error)") showTranscriptionError(error) } } @MainActor private func showTranscriptionResult(for filename: String, result: String) async { // Create a simple notification for now let notification = NSUserNotification() notification.title = "Transcription Complete" notification.subtitle = filename notification.informativeText = String(result.prefix(100)) + (result.count > 100 ? "..." : "") NSUserNotificationCenter.default.deliver(notification) // Also copy to clipboard let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(result, forType: .string) // Save to file await saveTranscriptionToFile(result, originalFilename: filename) } private func saveTranscriptionToFile(_ transcription: String, originalFilename: String) async { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" let timestamp = formatter.string(from: Date()) let sanitizedOriginalName = originalFilename .replacingOccurrences(of: ".", with: "_") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: ":", with: "_") let transcriptionFilename = "transcription_\(sanitizedOriginalName)_\(timestamp).txt" // Get the user's Desktop directory let desktopURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first! let fileURL = desktopURL.appendingPathComponent(transcriptionFilename) do { try transcription.write(to: fileURL, atomically: true, encoding: .utf8) logger.info("💾 Transcription saved to: \(fileURL.path)") } catch { logger.error("❌ Failed to save transcription to file: \(error.localizedDescription)") } } @MainActor private func showTranscriptionError(_ error: Error) { let notification = NSUserNotification() notification.title = "Transcription Failed" notification.informativeText = error.localizedDescription NSUserNotificationCenter.default.deliver(notification) } @MainActor private func openFileSelectionDialog() async { logger.info("🗂️ Opening file selection dialog") let openPanel = NSOpenPanel() openPanel.title = "Select Audio or Video Files to Transcribe" openPanel.message = "Choose audio or video files for transcription" openPanel.allowsMultipleSelection = true openPanel.canChooseDirectories = false openPanel.canChooseFiles = true openPanel.allowedContentTypes = [ .audio, .video, .mp3, .mpeg4Audio, .wav, .aiff, .movie, .quickTimeMovie, .avi ] let response = openPanel.runModal() if response == .OK { let selectedURLs = openPanel.urls logger.info("📁 Selected \(selectedURLs.count) file(s): \(selectedURLs.map { $0.lastPathComponent })") guard let queueManager = queueManager else { logger.error("❌ TranscriptionQueueManager not available, falling back to direct processing") await processFilesDirectly(selectedURLs) return } // Add files to queue queueManager.addFiles(selectedURLs) logger.info("✅ Added \(selectedURLs.count) files to transcription queue") // Show notification let notification = NSUserNotification() notification.title = "Files Added to Queue" notification.subtitle = "\(selectedURLs.count) file(s) queued for transcription" notification.informativeText = selectedURLs.map { $0.lastPathComponent }.joined(separator: ", ") NSUserNotificationCenter.default.deliver(notification) } else { logger.info("🚫 File selection cancelled") } } @MainActor private func processFilesDirectly(_ urls: [URL]) async { guard let fileManager = fileTranscriptionManager else { logger.error("❌ FileTranscriptionManager not available") return } // Transcribe selected files directly do { if urls.count == 1 { let result = try await fileManager.transcribeFile(at: urls[0]) logger.info("✅ Transcription completed: \(result.prefix(100))...") // Copy result to clipboard let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(result, forType: .string) logger.info("📋 Result copied to clipboard") } else { let results = try await fileManager.transcribeFiles(at: urls) let combinedResult = results.enumerated().map { index, result in "File \(index + 1) (\(urls[index].lastPathComponent)):\n\(result)" }.joined(separator: "\n\n") logger.info("✅ Batch transcription completed") // Copy combined results to clipboard let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(combinedResult, forType: .string) logger.info("📋 Combined results copied to clipboard") } } catch { logger.error("❌ Transcription failed: \(error.localizedDescription)") } } @MainActor private func handleClipboardURL(_ url: URL) async { logger.info("🔗 Adding clipboard URL to transcription queue: \(url.absoluteString)") guard let queueManager = queueManager else { logger.error("❌ TranscriptionQueueManager not available, falling back to direct processing") await handleClipboardURLDirectly(url) return } // Add URL to queue queueManager.addFile(url) logger.info("✅ Added URL to transcription queue") // Show notification let notification = NSUserNotification() notification.title = "URL Added to Queue" notification.subtitle = "Network file queued for transcription" notification.informativeText = url.absoluteString NSUserNotificationCenter.default.deliver(notification) } private func handleClipboardURLDirectly(_ url: URL) async { logger.info("🔗 Processing clipboard URL directly: \(url.absoluteString)") guard let fileManager = fileTranscriptionManager, let downloader = networkDownloader else { logger.error("❌ FileTranscriptionManager or NetworkFileDownloader not available") return } do { // Check if it's a YouTube URL if isYouTubeURL(url) { logger.info("🎬 Detected YouTube URL, using YouTube transcription manager") let youtubeManager = await YouTubeTranscriptionManager( fileTranscriptionManager: fileManager, networkDownloader: downloader ) let result = try await youtubeManager.transcribeYouTubeURL(url) // Show the result await showTranscriptionResult(for: "YouTube Video", result: result) } else { // Handle as regular network file let result: String = try await downloader.downloadAndTranscribe( from: url, using: fileManager, withTimestamps: false, deleteAfterTranscription: autoDeleteDownloadedFiles ) as! String let filename = url.lastPathComponent.isEmpty ? "Network File" : url.lastPathComponent await showTranscriptionResult(for: filename, result: result) } logger.info("✅ URL transcription completed") } catch { logger.error("❌ URL transcription failed: \(error.localizedDescription)") await showTranscriptionError(error) } } private func isYouTubeURL(_ url: URL) -> Bool { let host = url.host?.lowercased() return host == "youtube.com" || host == "www.youtube.com" || host == "youtu.be" || host == "m.youtube.com" } deinit { if let monitor = globalMonitor { NSEvent.removeMonitor(monitor) } if let monitor = localMonitor { NSEvent.removeMonitor(monitor) } if let monitor = fileSelectionGlobalMonitor { NSEvent.removeMonitor(monitor) } if let monitor = fileSelectionLocalMonitor { NSEvent.removeMonitor(monitor) } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sapoepsilon/Whispera'

If you have feedback or need assistance with the MCP directory API, please join our Discord server