VoiceInput/Sources/App/AppDelegate.swift
JOJO deae1fbc06 feat: RecordingPanel 宽度自适应 + 打包 DMG
- updateText 移除文本宽度硬编码下限 160,真正根据内容自适应
- showRefining/showThinking 简化为调用 updateText,复用自适应逻辑
- 面板最小宽度由 minPanelWidth(180pt) 兜底
- 打包 VoiceInput-20260425.dmg
2026-04-25 16:34:33 +08:00

347 lines
12 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
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()
}
}