- 新增 AssistOutputMode 配置(inject/notch) - Assist 设置界面添加输出方式弹出菜单 - 新建 NotchDisplayManager:DynamicNotchKit 弹窗,支持滚动、复制、15s 自动消失 - DynamicNotchKit 固化为本地依赖,修复假刘海高度问题(0→内容贴近顶部)
355 lines
12 KiB
Swift
355 lines
12 KiB
Swift
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)
|
||
|
||
if Config.shared.assistOutputMode == .notch {
|
||
// 刘海弹窗模式
|
||
recordingPanel.hideAnimated()
|
||
NotchDisplayManager.shared.show(text: result.text)
|
||
} else {
|
||
// 直接输入模式(默认)
|
||
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()
|
||
}
|
||
}
|