import Cocoa import AVFoundation import Speech /// 应用主控制器,协调 Fn 键(长按录音)和 Control 键(双击切换模式) final class AppDelegate: NSObject, NSApplicationDelegate { /// 防止 ARC 优化释放(NSApplication.delegate 是 weak) private static var retainedInstance: AppDelegate? private let keyMonitor = KeyMonitor() private let speechRecognizer = SpeechRecognizer() private let audioMonitor = AudioLevelMonitor() private let recordingPanel = RecordingPanel() private let menuBarController = MenuBarController() private let llmRefiner = LLMRefiner() private var isRecording = false private var finalTranscription: String = "" override init() { super.init() Self.retainedInstance = self } func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) beginPermissionFlow() print("[App] Ready") } // MARK: - Permissions — 顺序引导 private func beginPermissionFlow() { // 步骤1:辅助功能 guard CGPreflightListenEventAccess() else { openPrefsAndWait(panel: "Privacy_Accessibility", label: "辅助功能") { [weak self] in self?.beginPermissionFlow() } return } // 步骤2:输入监控 guard canCreateHIDEventTap() else { openPrefsAndWait(panel: "Privacy_InputMonitoring", label: "输入监控") { [weak self] in self?.beginPermissionFlow() } return } // 全部就绪 startKeyMonitor() showReadyAlert() } /// 打开系统设置面板,轮询等待权限,完成后回调 private func openPrefsAndWait(panel: String, label: String, onGranted: @escaping () -> Void) { NSWorkspace.shared.open( URL(string: "x-apple.systempreferences:com.apple.preference.security?\(panel)")! ) let alert = NSAlert() alert.messageText = "需要\(label)权限" alert.informativeText = "点击 + 号,选择 VoiceInput 应用,点击打开。" alert.alertStyle = .informational alert.addButton(withTitle: "继续") alert.addButton(withTitle: "退出") guard alert.runModal() == .alertFirstButtonReturn else { NSApp.terminate(nil) return } // 轮询检查 let check: () -> Bool = { if panel == "Privacy_Accessibility" { return CGPreflightListenEventAccess() } else { return self.canCreateHIDEventTap() } } Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in guard check() else { return } timer.invalidate() DispatchQueue.main.async { print("[Permission] ✅ \(label) granted") onGranted() } } } /// 检查输入监控权限 private func canCreateHIDEventTap() -> Bool { let eventMask = (1 << CGEventType.flagsChanged.rawValue) guard let tap = CGEvent.tapCreate( tap: .cghidEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(eventMask), callback: { _, _, _, _ in return nil }, userInfo: nil ) else { return false } CFMachPortInvalidate(tap) return true } private func showReadyAlert() { let alert = NSAlert() alert.messageText = "权限已就绪" alert.informativeText = "Voice Input 正在运行。首次使用时还需授权麦克风和语音识别。" alert.alertStyle = .informational alert.runModal() } // MARK: - 麦克风 / 语音识别权限 private func showMicrophonePermissionAlert() { let alert = NSAlert() alert.messageText = "需要麦克风权限" alert.informativeText = "请前往 系统设置 > 隐私与安全性 > 麦克风,开启 VoiceInput 的权限。" alert.alertStyle = .warning alert.addButton(withTitle: "打开系统设置") alert.addButton(withTitle: "取消") if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!) } } private func showSpeechPermissionAlert() { let alert = NSAlert() alert.messageText = "需要语音识别权限" alert.informativeText = "请前往 系统设置 > 隐私与安全性 > 语音识别,开启 VoiceInput 的权限。" alert.alertStyle = .warning alert.addButton(withTitle: "打开系统设置") alert.addButton(withTitle: "取消") if alert.runModal() == .alertFirstButtonReturn { NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition")!) } } // MARK: - Key Monitor private func startKeyMonitor() { let started = keyMonitor.start() if !started { print("[App] Failed to start key monitor") return } // Fn 键:长按录音 keyMonitor.onFnDown = { [weak self] in self?.startRecording() } keyMonitor.onFnUp = { [weak self] in self?.stopRecording() } // Control 键:双击切换模式 keyMonitor.onControlDoubleClick = { [weak self] in self?.toggleModeViaDoubleClick() } } // MARK: - Double-Click Mode Switch private func toggleModeViaDoubleClick() { let newMode: TriggerMode = Config.shared.triggerMode == .input ? .assist : .input Config.shared.triggerMode = newMode menuBarController.refreshModeMenu() print("[App] Control double-click: mode switched to \(newMode)") } // MARK: - Recording private func startRecording() { guard !isRecording else { return } isRecording = true finalTranscription = "" // 检查麦克风权限 let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) if micStatus == .denied || micStatus == .restricted { showMicrophonePermissionAlert() isRecording = false return } // 检查语音识别权限 let speechStatus = SFSpeechRecognizer.authorizationStatus() if speechStatus == .denied || speechStatus == .restricted { showSpeechPermissionAlert() isRecording = false return } if speechStatus == .notDetermined { // 触发系统弹窗 SFSpeechRecognizer.requestAuthorization { [weak self] status in DispatchQueue.main.async { self?.isRecording = false guard status == .authorized else { self?.showSpeechPermissionAlert() return } // 重新触发 self?.startRecording() } } return } recordingPanel.showAnimated() // 根据模式显示不同提示 let promptText = Config.shared.triggerMode == .input ? "Listening..." : "Ask AI..." recordingPanel.updateText(promptText) let language = Config.shared.language.rawValue Task { @MainActor in let audioOK = audioMonitor.start() if audioOK { audioMonitor.onLevelUpdate = { [weak self] level in self?.recordingPanel.waveformView.updateLevel(level) } } let speechOK = await speechRecognizer.start(language: language) if !speechOK { recordingPanel.updateText("Recognition error") DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.cancelRecording() } return } speechRecognizer.onResult = { [weak self] text in guard let self = self, self.isRecording else { return } self.finalTranscription = text self.recordingPanel.updateText(text) } speechRecognizer.onError = { error in let nsError = error as NSError if nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1110 { return } print("[App] Speech error: \(error)") } } } private func stopRecording() { guard isRecording else { return } isRecording = false audioMonitor.stop() _ = speechRecognizer.stop() let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { recordingPanel.hideAnimated() finalTranscription = "" return } // 根据 triggerMode 选择配置和逻辑 let mode = Config.shared.triggerMode let config = mode == .input ? Config.shared.inputLLM : Config.shared.assistLLM switch mode { case .input: handleInputMode(text: text, config: config) case .assist: handleAssistMode(text: text, config: config) } finalTranscription = "" } // MARK: - Input Mode (Fn) private func handleInputMode(text: String, config: LLMConfig) { guard config.enabled && config.isConfigured else { recordingPanel.hideAnimated { TextInjector.inject(text) } return } recordingPanel.showRefining() Task { @MainActor in do { let result = try await llmRefiner.refine(text: text, config: config) recordingPanel.hideAnimated { TextInjector.inject(result.text) } } catch { print("[App] LLM refine error: \(error)") recordingPanel.hideAnimated { TextInjector.inject(text) } } } } // MARK: - Assist Mode (Control) private func handleAssistMode(text: String, config: LLMConfig) { guard config.enabled && config.isConfigured else { recordingPanel.updateText("AI 助手未配置") DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in self?.recordingPanel.hideAnimated() } return } recordingPanel.showThinking() Task { @MainActor in do { let result = try await llmRefiner.chat(text: text, config: config) recordingPanel.hideAnimated { TextInjector.inject(result.text) } } catch { print("[App] LLM chat error: \(error)") recordingPanel.updateText("请求失败,请检查配置") DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in self?.recordingPanel.hideAnimated() } } } } private func cancelRecording() { audioMonitor.stop() speechRecognizer.cancel() recordingPanel.hideAnimated() isRecording = false finalTranscription = "" } func applicationWillTerminate(_ notification: Notification) { keyMonitor.stop() audioMonitor.stop() speechRecognizer.cancel() recordingPanel.close() } }