VoiceInput/Sources/App/AppDelegate.swift

244 lines
7.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Cocoa
/// Fn Control AI
final class AppDelegate: NSObject, NSApplicationDelegate {
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 = ""
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
//
if !CGPreflightListenEventAccess() {
requestAccessibilityPermission()
} else {
startKeyMonitor()
}
print("[App] Ready")
}
// MARK: - Permissions
private func requestAccessibilityPermission() {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
let alert = NSAlert()
alert.messageText = "需要辅助功能权限"
alert.informativeText = """
Voice Input 需要「辅助功能」权限才能监听 Fn 键和 Control 键。
系统设置已自动打开,请:
1. 点击左下角的锁并输入密码
2. 在列表中找到并勾选「VoiceInput」
3. 返回本应用
点击「继续」后,应用将每 2 秒检查一次权限状态。
"""
alert.alertStyle = .informational
alert.addButton(withTitle: "继续")
alert.addButton(withTitle: "退出")
if alert.runModal() == .alertFirstButtonReturn {
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in
if CGPreflightListenEventAccess() {
timer.invalidate()
DispatchQueue.main.async {
self?.startKeyMonitor()
let successAlert = NSAlert()
successAlert.messageText = "权限已授权"
successAlert.informativeText = "辅助功能权限已开启Voice Input 正在运行。"
successAlert.alertStyle = .informational
successAlert.runModal()
}
}
}
} else {
NSApp.terminate(nil)
}
}
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()
}
keyMonitor.onFnDoubleClick = { [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] Double-click: mode switched to \(newMode)")
}
// MARK: - Recording
private func startRecording() {
guard !isRecording else { return }
isRecording = true
finalTranscription = ""
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()
}
}