feat: macOS 菜单栏语音输入应用初始版本
- CGEvent cghidEventTap 全局监听 Fn 键 - Apple Speech 流式语音识别(zh-CN 默认) - 实时音频 RMS 电平驱动波形动画 - NSPanel 胶囊浮动窗口 - 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换 - OpenAI 兼容 LLM 纠错(DeepSeek 预设) - LSUIElement 菜单栏运行 - SPM + Makefile 构建
This commit is contained in:
commit
1d9c9d5fa0
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
*.app
|
||||
84
Makefile
Normal file
84
Makefile
Normal file
@ -0,0 +1,84 @@
|
||||
# VoiceInput Makefile
|
||||
# macOS 14+ 菜单栏语音输入应用
|
||||
|
||||
APP_NAME := VoiceInput
|
||||
BUILD_DIR := .build
|
||||
RELEASE_DIR := $(BUILD_DIR)/release
|
||||
PRODUCT := $(RELEASE_DIR)/$(APP_NAME)
|
||||
APP_BUNDLE := $(RELEASE_DIR)/$(APP_NAME).app
|
||||
INSTALL_DIR := /Applications/$(APP_NAME).app
|
||||
ENTITLEMENTS := Sources/Resources/VoiceInput.entitlements
|
||||
|
||||
.PHONY: all build run install clean debug release
|
||||
|
||||
all: build
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
# ============================================================
|
||||
build: debug
|
||||
|
||||
debug:
|
||||
@echo "🔨 Building Debug..."
|
||||
swift build -c debug --disable-sandbox
|
||||
@echo "📦 Creating app bundle..."
|
||||
@./scripts/create_app_bundle.sh debug
|
||||
@echo "✅ Build complete: $(APP_BUNDLE)"
|
||||
|
||||
release:
|
||||
@echo "🔨 Building Release..."
|
||||
swift build -c release --disable-sandbox
|
||||
@echo "📦 Creating app bundle..."
|
||||
@./scripts/create_app_bundle.sh release
|
||||
@echo "✅ Release build complete: $(APP_BUNDLE)"
|
||||
|
||||
# ============================================================
|
||||
# Run
|
||||
# ============================================================
|
||||
run: debug
|
||||
@echo "🚀 Launching..."
|
||||
open $(APP_BUNDLE)
|
||||
|
||||
# ============================================================
|
||||
# Install
|
||||
# ============================================================
|
||||
install: release
|
||||
@echo "📥 Installing to $(INSTALL_DIR)..."
|
||||
@if [ -d "$(INSTALL_DIR)" ]; then rm -rf "$(INSTALL_DIR)"; fi
|
||||
cp -R $(APP_BUNDLE) $(INSTALL_DIR)
|
||||
@echo "✅ Installed to $(INSTALL_DIR)"
|
||||
|
||||
# ============================================================
|
||||
# Clean
|
||||
# ============================================================
|
||||
clean:
|
||||
@echo "🧹 Cleaning..."
|
||||
swift package clean
|
||||
rm -rf $(RELEASE_DIR)
|
||||
@echo "✅ Clean"
|
||||
|
||||
# ============================================================
|
||||
# Entitlements
|
||||
# ============================================================
|
||||
entitlements: $(ENTITLEMENTS)
|
||||
|
||||
$(ENTITLEMENTS):
|
||||
@mkdir -p Sources/Resources
|
||||
@echo '<?xml version="1.0" encoding="UTF-8"?>' > $(ENTITLEMENTS)
|
||||
@echo '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' >> $(ENTITLEMENTS)
|
||||
@echo '<plist version="1.0">' >> $(ENTITLEMENTS)
|
||||
@echo '<dict>' >> $(ENTITLEMENTS)
|
||||
@echo ' <key>com.apple.security.device.audio-input</key>' >> $(ENTITLEMENTS)
|
||||
@echo ' <true/>' >> $(ENTITLEMENTS)
|
||||
@echo ' <key>com.apple.security.cs.disable-library-validation</key>' >> $(ENTITLEMENTS)
|
||||
@echo ' <true/>' >> $(ENTITLEMENTS)
|
||||
@echo '</dict>' >> $(ENTITLEMENTS)
|
||||
@echo '</plist>' >> $(ENTITLEMENTS)
|
||||
|
||||
# ============================================================
|
||||
# Codesign (ad-hoc)
|
||||
# ============================================================
|
||||
sign: release
|
||||
@echo "🔏 Signing..."
|
||||
codesign --force --deep --sign - $(APP_BUNDLE)
|
||||
@echo "✅ Signed (ad-hoc)"
|
||||
21
Package.swift
Normal file
21
Package.swift
Normal file
@ -0,0 +1,21 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "VoiceInput",
|
||||
platforms: [.macOS(.v14)],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "VoiceInput",
|
||||
path: "Sources",
|
||||
linkerSettings: [
|
||||
.linkedFramework("Speech"),
|
||||
.linkedFramework("AVFAudio"),
|
||||
.linkedFramework("Carbon"),
|
||||
.linkedFramework("CoreGraphics"),
|
||||
.linkedFramework("AppKit"),
|
||||
.linkedFramework("Foundation")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
159
Sources/App/AppDelegate.swift
Normal file
159
Sources/App/AppDelegate.swift
Normal file
@ -0,0 +1,159 @@
|
||||
import Cocoa
|
||||
|
||||
/// 应用主控制器,协调按键监听、语音识别、LLM 纠错和文本注入
|
||||
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)
|
||||
|
||||
// 启动按键监听
|
||||
let started = keyMonitor.start()
|
||||
if !started {
|
||||
showPermissionAlert()
|
||||
}
|
||||
|
||||
keyMonitor.onFnDown = { [weak self] in
|
||||
self?.startRecording()
|
||||
}
|
||||
|
||||
keyMonitor.onFnUp = { [weak self] in
|
||||
self?.stopRecording()
|
||||
}
|
||||
|
||||
print("[App] Ready")
|
||||
}
|
||||
|
||||
// MARK: - Recording
|
||||
|
||||
private func startRecording() {
|
||||
guard !isRecording else { return }
|
||||
isRecording = true
|
||||
finalTranscription = ""
|
||||
|
||||
recordingPanel.showAnimated()
|
||||
recordingPanel.updateText("Listening...")
|
||||
|
||||
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 = { [weak self] error in
|
||||
print("[App] Speech error: \(error)")
|
||||
self?.recordingPanel.updateText("Error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
speechRecognizer.onStateChange = { [weak self] isActive in
|
||||
if !isActive, self?.isRecording == true {
|
||||
// 识别意外停止
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopRecording() {
|
||||
guard isRecording else { return }
|
||||
isRecording = false
|
||||
|
||||
// 停止音频和识别
|
||||
audioMonitor.stop()
|
||||
_ = speechRecognizer.stop()
|
||||
|
||||
let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// 如果 LLM 启用且已配置
|
||||
if Config.shared.llmEnabled && Config.shared.isLLMConfigured && !text.isEmpty {
|
||||
recordingPanel.showRefining()
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let result = try await llmRefiner.refine(text: text, config: Config.shared)
|
||||
recordingPanel.hideAnimated {
|
||||
TextInjector.inject(result.refinedText)
|
||||
}
|
||||
} catch {
|
||||
print("[App] LLM error: \(error)")
|
||||
// LLM 失败,回退到原始文本
|
||||
recordingPanel.hideAnimated {
|
||||
TextInjector.inject(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !text.isEmpty {
|
||||
recordingPanel.hideAnimated {
|
||||
TextInjector.inject(text)
|
||||
}
|
||||
} else {
|
||||
recordingPanel.hideAnimated()
|
||||
}
|
||||
|
||||
finalTranscription = ""
|
||||
}
|
||||
|
||||
private func cancelRecording() {
|
||||
audioMonitor.stop()
|
||||
speechRecognizer.cancel()
|
||||
recordingPanel.hideAnimated()
|
||||
isRecording = false
|
||||
finalTranscription = ""
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
private func showPermissionAlert() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Accessibility Permission Required"
|
||||
alert.informativeText = """
|
||||
Voice Input needs Accessibility permission to detect the Fn key globally.
|
||||
|
||||
Please go to System Settings > Privacy & Security > Accessibility
|
||||
and enable Voice Input.
|
||||
"""
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "Open System Settings")
|
||||
alert.addButton(withTitle: "Later")
|
||||
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
keyMonitor.stop()
|
||||
audioMonitor.stop()
|
||||
speechRecognizer.cancel()
|
||||
recordingPanel.close()
|
||||
}
|
||||
}
|
||||
142
Sources/App/MenuBarController.swift
Normal file
142
Sources/App/MenuBarController.swift
Normal file
@ -0,0 +1,142 @@
|
||||
import Cocoa
|
||||
|
||||
/// 菜单栏控制器:管理状态栏图标和菜单
|
||||
final class MenuBarController: NSObject {
|
||||
|
||||
private let statusItem: NSStatusItem
|
||||
private let menu = NSMenu()
|
||||
private var languageMenuItems: [NSMenuItem] = []
|
||||
|
||||
// 子菜单引用
|
||||
private let llmSubmenu = NSMenu()
|
||||
private let llmEnabledItem: NSMenuItem
|
||||
private let settingsWindow = SettingsWindow()
|
||||
|
||||
override init() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
|
||||
// LLM 启用/禁用
|
||||
llmEnabledItem = NSMenuItem(
|
||||
title: "Enable LLM Refinement",
|
||||
action: #selector(toggleLLM),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
|
||||
super.init()
|
||||
|
||||
configureStatusItem()
|
||||
buildMenu()
|
||||
}
|
||||
|
||||
private func configureStatusItem() {
|
||||
if let button = statusItem.button {
|
||||
button.image = NSImage(
|
||||
systemSymbolName: "mic.fill",
|
||||
accessibilityDescription: "Voice Input"
|
||||
)
|
||||
button.toolTip = "Voice Input — 按住 Fn 键录音"
|
||||
}
|
||||
}
|
||||
|
||||
private func buildMenu() {
|
||||
// 当前语言显示
|
||||
let currentLanguageItem = NSMenuItem(
|
||||
title: "Language: \(Config.shared.language.displayName)",
|
||||
action: nil,
|
||||
keyEquivalent: ""
|
||||
)
|
||||
currentLanguageItem.isEnabled = false
|
||||
menu.addItem(currentLanguageItem)
|
||||
menu.addItem(.separator())
|
||||
|
||||
// 语言选择子菜单
|
||||
let languageMenu = NSMenu()
|
||||
for lang in RecognitionLanguage.allCases {
|
||||
let item = NSMenuItem(
|
||||
title: lang.displayName,
|
||||
action: #selector(selectLanguage(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
item.representedObject = lang
|
||||
item.state = lang == Config.shared.language ? .on : .off
|
||||
languageMenu.addItem(item)
|
||||
languageMenuItems.append(item)
|
||||
}
|
||||
|
||||
let languageSubmenuItem = NSMenuItem()
|
||||
languageSubmenuItem.title = "Language"
|
||||
languageSubmenuItem.submenu = languageMenu
|
||||
menu.addItem(languageSubmenuItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
// LLM 子菜单
|
||||
llmSubmenu.addItem(llmEnabledItem)
|
||||
llmEnabledItem.target = self
|
||||
llmEnabledItem.action = #selector(toggleLLM)
|
||||
updateLLMMenuState()
|
||||
|
||||
let settingsItem = NSMenuItem(
|
||||
title: "LLM Settings...",
|
||||
action: #selector(openSettings),
|
||||
keyEquivalent: ","
|
||||
)
|
||||
settingsItem.target = self
|
||||
llmSubmenu.addItem(settingsItem)
|
||||
|
||||
let llmSubmenuItem = NSMenuItem()
|
||||
llmSubmenuItem.title = "LLM Refinement"
|
||||
llmSubmenuItem.submenu = llmSubmenu
|
||||
menu.addItem(llmSubmenuItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
// 退出
|
||||
let quitItem = NSMenuItem(
|
||||
title: "Quit",
|
||||
action: #selector(quit),
|
||||
keyEquivalent: "q"
|
||||
)
|
||||
quitItem.target = self
|
||||
menu.addItem(quitItem)
|
||||
|
||||
statusItem.menu = menu
|
||||
}
|
||||
|
||||
private func updateLLMMenuState() {
|
||||
llmEnabledItem.state = Config.shared.llmEnabled ? .on : .off
|
||||
}
|
||||
|
||||
private func updateLanguageMenuState() {
|
||||
for item in languageMenuItems {
|
||||
guard let lang = item.representedObject as? RecognitionLanguage else { continue }
|
||||
item.state = lang == Config.shared.language ? .on : .off
|
||||
}
|
||||
// 更新第一项的显示
|
||||
if let first = menu.items.first {
|
||||
first.title = "Language: \(Config.shared.language.displayName)"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func selectLanguage(_ sender: NSMenuItem) {
|
||||
guard let lang = sender.representedObject as? RecognitionLanguage else { return }
|
||||
Config.shared.language = lang
|
||||
updateLanguageMenuState()
|
||||
print("[Menu] Language switched to: \(lang.displayName)")
|
||||
}
|
||||
|
||||
@objc private func toggleLLM() {
|
||||
Config.shared.llmEnabled.toggle()
|
||||
updateLLMMenuState()
|
||||
print("[Menu] LLM Refinement: \(Config.shared.llmEnabled ? "ON" : "OFF")")
|
||||
}
|
||||
|
||||
@objc private func openSettings() {
|
||||
settingsWindow.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
@objc private func quit() {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
66
Sources/Core/AudioLevelMonitor.swift
Normal file
66
Sources/Core/AudioLevelMonitor.swift
Normal file
@ -0,0 +1,66 @@
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
|
||||
/// 实时音频电平监测,通过 AVAudioEngine 获取麦克风 RMS 电平
|
||||
final class AudioLevelMonitor {
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
private var isRunning = false
|
||||
|
||||
/// RMS 电平回调,值范围约 0.0 ~ 1.0(正常语音),可能超过 1.0
|
||||
var onLevelUpdate: ((Float) -> Void)?
|
||||
|
||||
func start() -> Bool {
|
||||
guard !isRunning else { return true }
|
||||
|
||||
let inputNode = engine.inputNode
|
||||
let format = inputNode.outputFormat(forBus: 0)
|
||||
|
||||
// 安装 tap 获取实时音频缓冲
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
||||
self?.processAudioBuffer(buffer)
|
||||
}
|
||||
|
||||
do {
|
||||
try engine.start()
|
||||
isRunning = true
|
||||
print("[AudioLevel] Started")
|
||||
return true
|
||||
} catch {
|
||||
print("[AudioLevel] Failed to start engine: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isRunning else { return }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
isRunning = false
|
||||
print("[AudioLevel] Stopped")
|
||||
}
|
||||
|
||||
private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData else { return }
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
let channelCount = Int(buffer.format.channelCount)
|
||||
|
||||
// 计算所有通道的 RMS
|
||||
var sumSquares: Float = 0
|
||||
for channel in 0..<channelCount {
|
||||
let samples = channelData[channel]
|
||||
for i in 0..<frameLength {
|
||||
let sample = samples[i]
|
||||
sumSquares += sample * sample
|
||||
}
|
||||
}
|
||||
let rms = sqrt(sumSquares / Float(frameLength * channelCount))
|
||||
|
||||
// 放大约 15 倍,使正常语音峰值接近 1.0
|
||||
let scaled = min(rms * 15.0, 1.5)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onLevelUpdate?(scaled)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
Sources/Core/InputSourceManager.swift
Normal file
71
Sources/Core/InputSourceManager.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Carbon
|
||||
import Foundation
|
||||
|
||||
/// 管理输入法切换,用于在粘贴前临时切换到 ASCII 输入源
|
||||
struct InputSourceManager {
|
||||
|
||||
/// 获取当前输入源 ID
|
||||
static func currentInputSourceID() -> String? {
|
||||
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else {
|
||||
return nil
|
||||
}
|
||||
return TISGetInputSourceProperty(source, kTISPropertyInputSourceID)
|
||||
.flatMap { Unmanaged<CFString>.fromOpaque($0).takeUnretainedValue() as String }
|
||||
}
|
||||
|
||||
/// 判断当前输入源是否为 CJK 输入法
|
||||
static func isCJKInputSource() -> Bool {
|
||||
guard let id = currentInputSourceID() else { return false }
|
||||
let cjkPrefixes = [
|
||||
"com.apple.inputmethod.Kotoeri", // 日语
|
||||
"com.apple.inputmethod.SCIM", // 简体中文
|
||||
"com.apple.inputmethod.TCIM", // 繁体中文
|
||||
"com.apple.inputmethod.Korean", // 韩语
|
||||
"com.apple.inputmethod.2SetHangul",
|
||||
"com.apple.inputmethod.3SetHangul",
|
||||
"com.apple.inputmethod.390Hangul",
|
||||
"com.apple.inputmethod.GongjinCheong",
|
||||
"com.apple.inputmethod.HNC"
|
||||
]
|
||||
return cjkPrefixes.contains(where: { id.hasPrefix($0) })
|
||||
}
|
||||
|
||||
/// 查找 ASCII 输入源(ABC 键盘)
|
||||
static func findASCIIInputSource() -> TISInputSource? {
|
||||
let properties: [CFString: Any] = [
|
||||
kTISPropertyInputSourceType: kTISTypeKeyboardLayout as CFString,
|
||||
kTISPropertyInputSourceIsASCIICapable: true as CFBoolean
|
||||
]
|
||||
guard let list = TISCreateInputSourceList(properties as CFDictionary, false)?
|
||||
.takeRetainedValue() as? [TISInputSource] else {
|
||||
return nil
|
||||
}
|
||||
// 优先选择 "ABC" 或 "U.S."
|
||||
for source in list {
|
||||
if let id = TISGetInputSourceProperty(source, kTISPropertyInputSourceID)
|
||||
.flatMap({ Unmanaged<CFString>.fromOpaque($0).takeUnretainedValue() as String }) {
|
||||
if id == "com.apple.keylayout.ABC" || id == "com.apple.keylayout.US" {
|
||||
return source
|
||||
}
|
||||
}
|
||||
}
|
||||
// 回退到第一个 ASCII 能力的输入源
|
||||
return list.first
|
||||
}
|
||||
|
||||
/// 切换到指定输入源并返回原输入源
|
||||
static func switchToASCII() -> TISInputSource? {
|
||||
let original = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
|
||||
guard let asciiSource = findASCIIInputSource() else {
|
||||
return original
|
||||
}
|
||||
TISSelectInputSource(asciiSource)
|
||||
return original
|
||||
}
|
||||
|
||||
/// 恢复到指定输入源
|
||||
static func restore(_ source: TISInputSource?) {
|
||||
guard let source = source else { return }
|
||||
TISSelectInputSource(source)
|
||||
}
|
||||
}
|
||||
130
Sources/Core/KeyMonitor.swift
Normal file
130
Sources/Core/KeyMonitor.swift
Normal file
@ -0,0 +1,130 @@
|
||||
import Cocoa
|
||||
import CoreGraphics
|
||||
|
||||
/// 全局 Fn / Globe 键监听器
|
||||
final class KeyMonitor {
|
||||
|
||||
// Fn 键标志:CGEventFlags.maskSecondaryFn = 0x00800000
|
||||
private static let fnMask: UInt64 = 0x00800000
|
||||
|
||||
private var eventTap: CFMachPort?
|
||||
private var runLoopSource: CFRunLoopSource?
|
||||
private var isFnPressed = false
|
||||
|
||||
var onFnDown: (() -> Void)?
|
||||
var onFnUp: (() -> Void)?
|
||||
|
||||
func start() -> Bool {
|
||||
guard eventTap == nil else { return true }
|
||||
|
||||
guard CGPreflightListenEventAccess() else {
|
||||
CGRequestListenEventAccess()
|
||||
print("[KeyMonitor] ❌ Accessibility permission not granted")
|
||||
return false
|
||||
}
|
||||
|
||||
let eventMask = (1 << CGEventType.flagsChanged.rawValue)
|
||||
| (1 << CGEventType.keyDown.rawValue)
|
||||
| (1 << CGEventType.keyUp.rawValue)
|
||||
|
||||
let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
|
||||
let monitor = Unmanaged<KeyMonitor>.fromOpaque(refcon!).takeUnretainedValue()
|
||||
return monitor.handleEvent(proxy: proxy, type: type, event: event)
|
||||
}
|
||||
|
||||
// 尝试 HID 级 tap(能捕获被系统拦截的 Fn 键)
|
||||
var tap = CGEvent.tapCreate(
|
||||
tap: .cghidEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: CGEventMask(eventMask),
|
||||
callback: callback,
|
||||
userInfo: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
|
||||
if tap == nil {
|
||||
// 回退到会话级 tap
|
||||
print("[KeyMonitor] ⚠️ HID tap failed, falling back to session tap")
|
||||
tap = CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap,
|
||||
eventsOfInterest: CGEventMask(eventMask),
|
||||
callback: callback,
|
||||
userInfo: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
}
|
||||
|
||||
guard let tap = tap else {
|
||||
print("[KeyMonitor] ❌ Failed to create event tap")
|
||||
return false
|
||||
}
|
||||
|
||||
eventTap = tap
|
||||
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
|
||||
CGEvent.tapEnable(tap: tap, enable: true)
|
||||
|
||||
print("[KeyMonitor] ✅ Started")
|
||||
return true
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let tap = eventTap else { return }
|
||||
CGEvent.tapEnable(tap: tap, enable: false)
|
||||
if let source = runLoopSource {
|
||||
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
|
||||
}
|
||||
eventTap = nil
|
||||
runLoopSource = nil
|
||||
print("[KeyMonitor] Stopped")
|
||||
}
|
||||
|
||||
private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
|
||||
let flags = event.flags.rawValue
|
||||
let fnInFlags = (flags & Self.fnMask) != 0
|
||||
|
||||
switch type {
|
||||
case .flagsChanged:
|
||||
if fnInFlags && !isFnPressed {
|
||||
print("[KeyMonitor] ⚡️ Fn DOWN (flags=0x\(String(flags, radix: 16)))")
|
||||
return handleFnDown()
|
||||
}
|
||||
if !fnInFlags && isFnPressed {
|
||||
print("[KeyMonitor] ⚡️ Fn UP (flags=0x\(String(flags, radix: 16)))")
|
||||
return handleFnUp()
|
||||
}
|
||||
return Unmanaged.passUnretained(event)
|
||||
|
||||
case .keyDown, .keyUp:
|
||||
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
|
||||
if keyCode == 63 && fnInFlags {
|
||||
return handleFnDown()
|
||||
}
|
||||
return Unmanaged.passUnretained(event)
|
||||
|
||||
default:
|
||||
return Unmanaged.passUnretained(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFnDown() -> Unmanaged<CGEvent>? {
|
||||
if !isFnPressed {
|
||||
isFnPressed = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onFnDown?()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func handleFnUp() -> Unmanaged<CGEvent>? {
|
||||
if isFnPressed {
|
||||
isFnPressed = false
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onFnUp?()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
97
Sources/Core/LLMRefiner.swift
Normal file
97
Sources/Core/LLMRefiner.swift
Normal file
@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
/// 通过 OpenAI 兼容 API 对转录文本进行纠错优化
|
||||
final class LLMRefiner {
|
||||
|
||||
private let session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 15
|
||||
config.timeoutIntervalForResource = 20
|
||||
return URLSession(configuration: config)
|
||||
}()
|
||||
|
||||
/// 纠错结果
|
||||
struct RefinementResult {
|
||||
let refinedText: String
|
||||
let wasChanged: Bool
|
||||
}
|
||||
|
||||
/// 对转录文本进行纠错
|
||||
/// - Parameters:
|
||||
/// - text: 原始转录文本
|
||||
/// - config: LLM 配置
|
||||
/// - Returns: 纠错结果
|
||||
func refine(text: String, config: Config) async throws -> RefinementResult {
|
||||
let baseURL = config.llmBaseURL
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let url = URL(string: "\(baseURL)/v1/chat/completions")!
|
||||
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.httpMethod = "POST"
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
urlRequest.setValue("Bearer \(config.llmAPIKey)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let systemPrompt = """
|
||||
你是一个语音识别后处理工具。你唯一的任务是修正转录文本中的明显识别错误。规则:
|
||||
1. 修正中文同音字/词错误。语音识别经常把技术词汇识别为同音的日常词汇,例如:
|
||||
「变异」→「编译」、「程序圆」→「程序员」、「配森」→「Python」。
|
||||
请根据上下文判断修正方向,将同音错误还原为正确的词汇。
|
||||
2. 修正被错误转录为中文的英文技术术语。
|
||||
3. 修正极其明显的中文和英文拼写错误(包括错别字、漏字、多字)。
|
||||
4. 修正明显的乱码或无意义字符序列。
|
||||
5. 绝不重写、润色、改进或修改任何看起来正确的内容。
|
||||
6. 如果输入本身正确,请原样返回,不做任何修改。
|
||||
7. 保留所有标点符号、空格、大小写和格式。
|
||||
8. 只返回修正后的文本,不要添加任何解释、前缀、后缀或 Markdown 标记。
|
||||
"""
|
||||
|
||||
let messages: [[String: String]] = [
|
||||
["role": "system", "content": systemPrompt],
|
||||
["role": "user", "content": text]
|
||||
]
|
||||
|
||||
let body: [String: Any] = [
|
||||
"model": config.llmModel,
|
||||
"messages": messages,
|
||||
"max_tokens": 4096,
|
||||
"thinking": ["type": "disabled"]
|
||||
]
|
||||
|
||||
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await session.data(for: urlRequest)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NSError(domain: "LLMRefiner", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8) ?? "Unknown"
|
||||
throw NSError(domain: "LLMRefiner", code: httpResponse.statusCode,
|
||||
userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode): \(body)"])
|
||||
}
|
||||
|
||||
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let choices = json["choices"] as? [[String: Any]],
|
||||
let firstChoice = choices.first,
|
||||
let message = firstChoice["message"] as? [String: Any],
|
||||
let refined = message["content"] as? String else {
|
||||
throw NSError(domain: "LLMRefiner", code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Failed to parse response"])
|
||||
}
|
||||
|
||||
let trimmed = refined.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let wasChanged = trimmed != text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
print("[LLM] Refinement \(wasChanged ? "changed" : "unchanged"): \"\(trimmed.prefix(80))...\"")
|
||||
return RefinementResult(refinedText: trimmed, wasChanged: wasChanged)
|
||||
}
|
||||
|
||||
/// 测试连接
|
||||
func testConnection(config: Config) async throws -> String {
|
||||
let testText = "测试连接"
|
||||
let result = try await refine(text: testText, config: config)
|
||||
return result.refinedText
|
||||
}
|
||||
}
|
||||
133
Sources/Core/SpeechRecognizer.swift
Normal file
133
Sources/Core/SpeechRecognizer.swift
Normal file
@ -0,0 +1,133 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
import AVFAudio
|
||||
|
||||
/// 流式语音识别,使用 Apple Speech Recognition 框架
|
||||
final class SpeechRecognizer: NSObject, SFSpeechRecognizerDelegate {
|
||||
|
||||
private var recognizer: SFSpeechRecognizer?
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
private let engine = AVAudioEngine()
|
||||
private var isRunning = false
|
||||
|
||||
var onResult: ((String) -> Void)?
|
||||
var onError: ((Error) -> Void)?
|
||||
var onStateChange: ((Bool) -> Void)? // true = 开始识别中
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// 请求权限
|
||||
func requestAuthorization() async -> Bool {
|
||||
let status = await withCheckedContinuation { (continuation: CheckedContinuation<SFSpeechRecognizerAuthorizationStatus, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
return status == .authorized
|
||||
}
|
||||
|
||||
/// 使用指定语言启动识别
|
||||
func start(language: String) async -> Bool {
|
||||
guard !isRunning else { return true }
|
||||
|
||||
let authorized = await requestAuthorization()
|
||||
guard authorized else {
|
||||
onError?(NSError(domain: "SpeechRecognizer", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Speech recognition not authorized"]))
|
||||
return false
|
||||
}
|
||||
|
||||
guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: language)),
|
||||
recognizer.isAvailable else {
|
||||
let msg = "Speech recognizer unavailable for \(language)"
|
||||
onError?(NSError(domain: "SpeechRecognizer", code: -2,
|
||||
userInfo: [NSLocalizedDescriptionKey: msg]))
|
||||
return false
|
||||
}
|
||||
|
||||
self.recognizer = recognizer
|
||||
recognizer.delegate = self
|
||||
recognizer.defaultTaskHint = .dictation
|
||||
|
||||
// 创建新的请求
|
||||
request = SFSpeechAudioBufferRecognitionRequest()
|
||||
guard let request = request else { return false }
|
||||
request.shouldReportPartialResults = true
|
||||
request.addsPunctuation = true
|
||||
|
||||
// 获取音频输入
|
||||
let inputNode = engine.inputNode
|
||||
let format = inputNode.outputFormat(forBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
|
||||
self?.request?.append(buffer)
|
||||
}
|
||||
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
onError?(error)
|
||||
return false
|
||||
}
|
||||
|
||||
// 开始识别任务
|
||||
task = recognizer.recognitionTask(with: request) { [weak self] result, error in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
DispatchQueue.main.async {
|
||||
self.onError?(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let result = result {
|
||||
// 获取当前最佳转录及未完成的备选
|
||||
let text = result.bestTranscription.formattedString
|
||||
DispatchQueue.main.async {
|
||||
self.onResult?(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
onStateChange?(true)
|
||||
print("[Speech] Started recognition for \(language)")
|
||||
return true
|
||||
}
|
||||
|
||||
func stop() -> String? {
|
||||
guard isRunning else { return nil }
|
||||
isRunning = false
|
||||
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
|
||||
// 结束识别流
|
||||
request?.endAudio()
|
||||
task?.finish()
|
||||
|
||||
// 结果已在回调中处理
|
||||
request = nil
|
||||
task = nil
|
||||
recognizer = nil
|
||||
|
||||
onStateChange?(false)
|
||||
print("[Speech] Stopped recognition")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
task?.cancel()
|
||||
_ = stop()
|
||||
}
|
||||
|
||||
// MARK: - SFSpeechRecognizerDelegate
|
||||
|
||||
func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer,
|
||||
availabilityDidChange available: Bool) {
|
||||
print("[Speech] Availability changed: \(available)")
|
||||
}
|
||||
}
|
||||
105
Sources/Core/TextInjector.swift
Normal file
105
Sources/Core/TextInjector.swift
Normal file
@ -0,0 +1,105 @@
|
||||
import Cocoa
|
||||
import Carbon
|
||||
|
||||
/// 通过剪贴板备份 → 写入 → Cmd+V 粘贴 → 恢复剪贴板的方式注入文本
|
||||
struct TextInjector {
|
||||
|
||||
/// 剪贴板备份结构
|
||||
private struct PasteboardBackup {
|
||||
let items: [[String: Data]]
|
||||
}
|
||||
|
||||
/// 将文本注入当前焦点输入框(异步完成剪贴板恢复)
|
||||
@discardableResult
|
||||
static func inject(_ text: String) -> Bool {
|
||||
guard !text.isEmpty else { return false }
|
||||
|
||||
// 备份
|
||||
let backup = backupPasteboard()
|
||||
|
||||
// 检测输入法
|
||||
let isCJK = InputSourceManager.isCJKInputSource()
|
||||
let originalSource: TISInputSource? = isCJK ? InputSourceManager.switchToASCII() : nil
|
||||
|
||||
if isCJK {
|
||||
Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
|
||||
// 写入目标文本
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
|
||||
// 模拟 Cmd+V(必须在主线程 run loop 中处理)
|
||||
simulatePaste()
|
||||
|
||||
// 恢复输入法(不阻塞主线程)
|
||||
if isCJK {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
InputSourceManager.restore(originalSource)
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟恢复剪贴板,确保粘贴事件已被目标应用处理
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
restorePasteboard(backup)
|
||||
}
|
||||
|
||||
print("[Injector] Injected \"\(text.prefix(50))...\" (\(text.count) chars)")
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - 剪贴板备份/恢复
|
||||
|
||||
private static func backupPasteboard() -> PasteboardBackup {
|
||||
let pb = NSPasteboard.general
|
||||
guard let items = pb.pasteboardItems, !items.isEmpty else {
|
||||
var fallback: [[String: Data]] = []
|
||||
if let str = pb.string(forType: .string),
|
||||
let data = str.data(using: .utf8) {
|
||||
fallback = [["public.utf8-plain-text": data]]
|
||||
}
|
||||
return PasteboardBackup(items: fallback)
|
||||
}
|
||||
|
||||
let backedItems: [[String: Data]] = items.compactMap { item in
|
||||
var typeData: [String: Data] = [:]
|
||||
for type in item.types {
|
||||
if let data = item.data(forType: type) {
|
||||
typeData[type.rawValue] = data
|
||||
}
|
||||
}
|
||||
return typeData.isEmpty ? nil : typeData
|
||||
}
|
||||
|
||||
return PasteboardBackup(items: backedItems)
|
||||
}
|
||||
|
||||
private static func restorePasteboard(_ backup: PasteboardBackup) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
|
||||
for typeData in backup.items {
|
||||
let newItem = NSPasteboardItem()
|
||||
for (type, data) in typeData {
|
||||
newItem.setData(data, forType: NSPasteboard.PasteboardType(type))
|
||||
}
|
||||
pb.writeObjects([newItem])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 模拟粘贴
|
||||
|
||||
private static func simulatePaste() {
|
||||
let source = CGEventSource(stateID: .combinedSessionState)
|
||||
|
||||
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
|
||||
keyDown?.flags = .maskCommand
|
||||
keyDown?.post(tap: .cghidEventTap)
|
||||
|
||||
usleep(10_000)
|
||||
|
||||
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
|
||||
keyUp?.flags = .maskCommand
|
||||
keyUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
}
|
||||
30
Sources/Resources/Info.plist
Normal file
30
Sources/Resources/Info.plist
Normal file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>zh_CN</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>VoiceInput</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.voiceinput.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>VoiceInput</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Voice Input needs microphone access to record your speech for transcription.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Voice Input needs speech recognition access to transcribe your voice into text.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
254
Sources/UI/RecordingPanel.swift
Normal file
254
Sources/UI/RecordingPanel.swift
Normal file
@ -0,0 +1,254 @@
|
||||
import Cocoa
|
||||
|
||||
/// 录音期间屏幕底部居中的胶囊形浮动窗口
|
||||
final class RecordingPanel: NSPanel {
|
||||
|
||||
// MARK: - 子视图
|
||||
let waveformView: WaveformView
|
||||
private let textLabel: NSTextField
|
||||
private let containerStack: NSStackView
|
||||
|
||||
// 文本宽度约束
|
||||
private var textWidthConstraint: NSLayoutConstraint?
|
||||
|
||||
// 窗口宽度常量
|
||||
private let minPanelWidth: CGFloat = 180
|
||||
private let maxPanelWidth: CGFloat = 680
|
||||
private let panelHeight: CGFloat = 56
|
||||
private let cornerRadius: CGFloat = 28
|
||||
private let waveformSize = NSSize(width: 44, height: 32)
|
||||
private let marginAboveDock: CGFloat = 12
|
||||
|
||||
init() {
|
||||
// Waveform
|
||||
waveformView = WaveformView(frame: NSRect(origin: .zero, size: waveformSize))
|
||||
waveformView.translatesAutoresizingMaskIntoConstraints = false
|
||||
waveformView.wantsLayer = true
|
||||
|
||||
// Label
|
||||
textLabel = NSTextField(labelWithString: "")
|
||||
textLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
textLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
textLabel.textColor = .white
|
||||
textLabel.alignment = .left
|
||||
textLabel.lineBreakMode = .byTruncatingTail
|
||||
textLabel.maximumNumberOfLines = 1
|
||||
textLabel.backgroundColor = .clear
|
||||
textLabel.isBezeled = false
|
||||
textLabel.drawsBackground = false
|
||||
|
||||
// Stack
|
||||
containerStack = NSStackView(views: [waveformView, textLabel])
|
||||
containerStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
containerStack.orientation = .horizontal
|
||||
containerStack.alignment = .centerY
|
||||
containerStack.spacing = 12
|
||||
containerStack.edgeInsets = NSEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
|
||||
// Panel 配置
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
configurePanel()
|
||||
configureVisualEffect()
|
||||
setupConstraints()
|
||||
}
|
||||
|
||||
private func configurePanel() {
|
||||
isFloatingPanel = true
|
||||
becomesKeyOnlyIfNeeded = true
|
||||
isOpaque = false
|
||||
hasShadow = false
|
||||
level = .floating
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
ignoresMouseEvents = false
|
||||
titleVisibility = .hidden
|
||||
titlebarAppearsTransparent = true
|
||||
isMovableByWindowBackground = false
|
||||
|
||||
// 彻底去除边框
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
private func configureVisualEffect() {
|
||||
let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight))
|
||||
visualEffect.material = .hudWindow
|
||||
visualEffect.blendingMode = .behindWindow
|
||||
visualEffect.state = .active
|
||||
visualEffect.wantsLayer = true
|
||||
visualEffect.layer?.cornerRadius = cornerRadius
|
||||
visualEffect.layer?.masksToBounds = true
|
||||
visualEffect.layer?.borderWidth = 0
|
||||
visualEffect.layer?.borderColor = NSColor.clear.cgColor
|
||||
visualEffect.autoresizingMask = [.width, .height]
|
||||
|
||||
// 替换 contentView
|
||||
contentView = visualEffect
|
||||
visualEffect.addSubview(containerStack)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
guard let content = contentView else { return }
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
// Waveform 固定尺寸
|
||||
waveformView.widthAnchor.constraint(equalToConstant: waveformSize.width),
|
||||
waveformView.heightAnchor.constraint(equalToConstant: waveformSize.height),
|
||||
|
||||
// Container 填充
|
||||
containerStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
||||
containerStack.centerYAnchor.constraint(equalTo: content.centerYAnchor),
|
||||
containerStack.heightAnchor.constraint(equalToConstant: panelHeight)
|
||||
])
|
||||
|
||||
// 文本弹性宽度
|
||||
textWidthConstraint = textLabel.widthAnchor.constraint(equalToConstant: 160)
|
||||
textWidthConstraint?.isActive = true
|
||||
}
|
||||
|
||||
/// 更新转录文本
|
||||
func updateText(_ text: String) {
|
||||
textLabel.stringValue = text
|
||||
|
||||
// 计算所需宽度
|
||||
let maxTextWidth = maxPanelWidth - waveformSize.width - 40 // 40 = spacing + insets
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: textLabel.font!]
|
||||
let textSize = (text as NSString).size(withAttributes: attributes)
|
||||
let requiredTextWidth = min(max(textSize.width + 10, 160), maxTextWidth)
|
||||
let targetPanelWidth = requiredTextWidth + waveformSize.width + 44
|
||||
|
||||
// 动画更新宽度
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.25
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
ctx.allowsImplicitAnimation = true
|
||||
|
||||
textWidthConstraint?.animator().constant = requiredTextWidth
|
||||
|
||||
let frame = currentScreenFrame()
|
||||
let y = bottomY(for: frame)
|
||||
let newFrame = NSRect(
|
||||
x: (frame.width - targetPanelWidth) / 2,
|
||||
y: y,
|
||||
width: targetPanelWidth,
|
||||
height: panelHeight
|
||||
)
|
||||
animator().setFrame(newFrame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示面板(弹簧动画)
|
||||
func showAnimated() {
|
||||
let frame = currentScreenFrame()
|
||||
let y = bottomY(for: frame)
|
||||
|
||||
let initialFrame = NSRect(
|
||||
x: (frame.width - minPanelWidth) / 2,
|
||||
y: y,
|
||||
width: minPanelWidth,
|
||||
height: panelHeight
|
||||
)
|
||||
|
||||
// 初始状态:缩小且透明
|
||||
setFrame(initialFrame, display: false)
|
||||
alphaValue = 0
|
||||
|
||||
// 出场
|
||||
makeKeyAndOrderFront(nil)
|
||||
|
||||
// 弹簧动画
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.35
|
||||
ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.0, 0.5, 0.3, 1.5) // 弹簧曲线
|
||||
animator().alphaValue = 1.0
|
||||
}
|
||||
// 弹簧效果通过 CATransaction 模拟
|
||||
let spring = CASpringAnimation(keyPath: "transform.scale")
|
||||
spring.fromValue = 0.85
|
||||
spring.toValue = 1.0
|
||||
spring.duration = 0.35
|
||||
spring.damping = 12
|
||||
spring.initialVelocity = 0
|
||||
spring.fillMode = .backwards
|
||||
contentView?.layer?.add(spring, forKey: "showSpring")
|
||||
}
|
||||
|
||||
/// 隐藏面板(缩放动画)
|
||||
func hideAnimated(completion: (() -> Void)? = nil) {
|
||||
NSAnimationContext.runAnimationGroup({ ctx in
|
||||
ctx.duration = 0.22
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
animator().alphaValue = 0
|
||||
|
||||
// 缩放
|
||||
let scale = CABasicAnimation(keyPath: "transform.scale")
|
||||
scale.fromValue = 1.0
|
||||
scale.toValue = 0.9
|
||||
scale.duration = 0.22
|
||||
scale.timingFunction = CAMediaTimingFunction(name: .easeIn)
|
||||
scale.fillMode = .forwards
|
||||
scale.isRemovedOnCompletion = false
|
||||
contentView?.layer?.add(scale, forKey: "hideScale")
|
||||
|
||||
}, completionHandler: {
|
||||
self.orderOut(nil)
|
||||
self.contentView?.layer?.removeAnimation(forKey: "hideScale")
|
||||
self.textLabel.stringValue = ""
|
||||
self.textWidthConstraint?.animator().constant = 160
|
||||
completion?()
|
||||
})
|
||||
}
|
||||
|
||||
/// 显示 Refining 状态(弹窗自适应缩窄)
|
||||
func showRefining() {
|
||||
textLabel.stringValue = "Refining..."
|
||||
let refiningTextWidth: CGFloat = 120
|
||||
let targetWidth: CGFloat = refiningTextWidth + waveformSize.width + 44
|
||||
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.2
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
ctx.allowsImplicitAnimation = true
|
||||
|
||||
textWidthConstraint?.animator().constant = refiningTextWidth
|
||||
|
||||
let frame = currentScreenFrame()
|
||||
let y = bottomY(for: frame)
|
||||
let newFrame = NSRect(
|
||||
x: (frame.width - targetWidth) / 2,
|
||||
y: y,
|
||||
width: targetWidth,
|
||||
height: panelHeight
|
||||
)
|
||||
animator().setFrame(newFrame, display: true)
|
||||
}
|
||||
|
||||
waveformView.updateLevel(0.3)
|
||||
}
|
||||
|
||||
// 重写:允许在非 active 状态响应
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
// MARK: - 屏幕坐标
|
||||
|
||||
private func currentScreenFrame() -> NSRect {
|
||||
// 优先使用鼠标所在屏幕,否则主屏幕
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
for screen in NSScreen.screens {
|
||||
if screen.frame.contains(mouseLocation) {
|
||||
return screen.visibleFrame
|
||||
}
|
||||
}
|
||||
return NSScreen.main?.visibleFrame ?? .zero
|
||||
}
|
||||
|
||||
private func bottomY(for screenFrame: NSRect) -> CGFloat {
|
||||
screenFrame.minY + marginAboveDock
|
||||
}
|
||||
}
|
||||
207
Sources/UI/SettingsWindow.swift
Normal file
207
Sources/UI/SettingsWindow.swift
Normal file
@ -0,0 +1,207 @@
|
||||
import Cocoa
|
||||
|
||||
/// LLM 设置窗口
|
||||
final class SettingsWindow: NSWindow {
|
||||
|
||||
private let baseURLField: NSTextField
|
||||
private let apiKeyField: NSSecureTextField
|
||||
private let modelField: NSTextField
|
||||
private let testButton: NSButton
|
||||
private let saveButton: NSButton
|
||||
private let statusLabel: NSTextField
|
||||
|
||||
init() {
|
||||
// 表单字段
|
||||
baseURLField = NSTextField(frame: .zero)
|
||||
apiKeyField = NSSecureTextField(frame: .zero)
|
||||
modelField = NSTextField(frame: .zero)
|
||||
testButton = NSButton(title: "Test", target: nil, action: nil)
|
||||
saveButton = NSButton(title: "Save", target: nil, action: nil)
|
||||
statusLabel = NSTextField(labelWithString: "")
|
||||
|
||||
let windowRect = NSRect(x: 0, y: 0, width: 420, height: 280)
|
||||
super.init(
|
||||
contentRect: windowRect,
|
||||
styleMask: [.titled, .closable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
configureWindow()
|
||||
buildUI()
|
||||
loadCurrentConfig()
|
||||
}
|
||||
|
||||
private func configureWindow() {
|
||||
title = "LLM Settings"
|
||||
isReleasedWhenClosed = false
|
||||
center()
|
||||
}
|
||||
|
||||
private func buildUI() {
|
||||
guard let content = contentView else { return }
|
||||
|
||||
// 标签
|
||||
func makeLabel(_ text: String) -> NSTextField {
|
||||
let label = NSTextField(labelWithString: text)
|
||||
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||
label.textColor = .secondaryLabelColor
|
||||
label.alignment = .right
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
return label
|
||||
}
|
||||
|
||||
func makeField(_ placeholder: String, isSecure: Bool = false) -> NSTextField {
|
||||
let field = isSecure ? NSSecureTextField(frame: .zero) : NSTextField(frame: .zero)
|
||||
field.placeholderString = placeholder
|
||||
field.font = .systemFont(ofSize: 13)
|
||||
field.bezelStyle = .roundedBezel
|
||||
field.translatesAutoresizingMaskIntoConstraints = false
|
||||
return field
|
||||
}
|
||||
|
||||
let baseURLLabel = makeLabel("API Base URL:")
|
||||
let apiKeyLabel = makeLabel("API Key:")
|
||||
let modelLabel = makeLabel("Model:")
|
||||
|
||||
// 使用实例字段
|
||||
baseURLField.placeholderString = "https://api.openai.com"
|
||||
baseURLField.font = .systemFont(ofSize: 13)
|
||||
baseURLField.bezelStyle = .roundedBezel
|
||||
baseURLField.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
apiKeyField.placeholderString = "sk-..."
|
||||
apiKeyField.font = .systemFont(ofSize: 13)
|
||||
apiKeyField.bezelStyle = .roundedBezel
|
||||
apiKeyField.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
modelField.placeholderString = "gpt-3.5-turbo"
|
||||
modelField.font = .systemFont(ofSize: 13)
|
||||
modelField.bezelStyle = .roundedBezel
|
||||
modelField.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
testButton.bezelStyle = .rounded
|
||||
testButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
testButton.target = self
|
||||
testButton.action = #selector(testConnection)
|
||||
|
||||
saveButton.bezelStyle = .rounded
|
||||
saveButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
saveButton.keyEquivalent = "\r"
|
||||
saveButton.target = self
|
||||
saveButton.action = #selector(saveConfig)
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: 11)
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.alignment = .center
|
||||
statusLabel.maximumNumberOfLines = 2
|
||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// 布局
|
||||
let grid = NSGridView(views: [
|
||||
[baseURLLabel, baseURLField],
|
||||
[apiKeyLabel, apiKeyField],
|
||||
[modelLabel, modelField]
|
||||
])
|
||||
grid.translatesAutoresizingMaskIntoConstraints = false
|
||||
grid.column(at: 0).xPlacement = .trailing
|
||||
grid.column(at: 1).width = 260
|
||||
grid.rowSpacing = 12
|
||||
grid.columnSpacing = 8
|
||||
|
||||
let buttonStack = NSStackView(views: [testButton, saveButton])
|
||||
buttonStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
buttonStack.orientation = .horizontal
|
||||
buttonStack.spacing = 12
|
||||
buttonStack.distribution = .fillEqually
|
||||
|
||||
content.addSubview(grid)
|
||||
content.addSubview(buttonStack)
|
||||
content.addSubview(statusLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||
grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
|
||||
grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
|
||||
|
||||
buttonStack.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 20),
|
||||
buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
||||
buttonStack.widthAnchor.constraint(equalToConstant: 220),
|
||||
|
||||
statusLabel.topAnchor.constraint(equalTo: buttonStack.bottomAnchor, constant: 12),
|
||||
statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor),
|
||||
statusLabel.widthAnchor.constraint(equalToConstant: 380)
|
||||
])
|
||||
}
|
||||
|
||||
private func loadCurrentConfig() {
|
||||
baseURLField.stringValue = Config.shared.llmBaseURL
|
||||
apiKeyField.stringValue = Config.shared.llmAPIKey
|
||||
modelField.stringValue = Config.shared.llmModel
|
||||
}
|
||||
|
||||
@objc private func testConnection() {
|
||||
// 使用输入框当前值进行测试
|
||||
|
||||
statusLabel.stringValue = "Testing..."
|
||||
statusLabel.textColor = .systemBlue
|
||||
|
||||
testButton.isEnabled = false
|
||||
saveButton.isEnabled = false
|
||||
|
||||
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let key = apiKeyField.stringValue
|
||||
let model = modelField.stringValue
|
||||
|
||||
guard !url.isEmpty, !key.isEmpty, !model.isEmpty else {
|
||||
statusLabel.stringValue = "Please fill all fields"
|
||||
statusLabel.textColor = .systemRed
|
||||
testButton.isEnabled = true
|
||||
saveButton.isEnabled = true
|
||||
return
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
let refiner = LLMRefiner()
|
||||
// 临时写入配置
|
||||
let original = (Config.shared.llmBaseURL, Config.shared.llmAPIKey, Config.shared.llmModel)
|
||||
Config.shared.llmBaseURL = url
|
||||
Config.shared.llmAPIKey = key
|
||||
Config.shared.llmModel = model
|
||||
|
||||
do {
|
||||
let result = try await refiner.testConnection(config: Config.shared)
|
||||
statusLabel.stringValue = "✅ Connected! Response: \(result)"
|
||||
statusLabel.textColor = .systemGreen
|
||||
} catch {
|
||||
statusLabel.stringValue = "❌ Error: \(error.localizedDescription)"
|
||||
statusLabel.textColor = .systemRed
|
||||
}
|
||||
|
||||
// 恢复
|
||||
Config.shared.llmBaseURL = original.0
|
||||
Config.shared.llmAPIKey = original.1
|
||||
Config.shared.llmModel = original.2
|
||||
|
||||
testButton.isEnabled = true
|
||||
saveButton.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func saveConfig() {
|
||||
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
let key = apiKeyField.stringValue
|
||||
let model = modelField.stringValue
|
||||
|
||||
Config.shared.llmBaseURL = url
|
||||
Config.shared.llmAPIKey = key
|
||||
Config.shared.llmModel = model
|
||||
|
||||
statusLabel.stringValue = "✅ Saved"
|
||||
statusLabel.textColor = .systemGreen
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
||||
self?.statusLabel.stringValue = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
100
Sources/UI/WaveformView.swift
Normal file
100
Sources/UI/WaveformView.swift
Normal file
@ -0,0 +1,100 @@
|
||||
import Cocoa
|
||||
|
||||
/// 5 个球形波动画,由实时音频 RMS 电平驱动
|
||||
final class WaveformView: NSView {
|
||||
|
||||
private let weights: [Float] = [0.5, 0.8, 1.0, 0.75, 0.55]
|
||||
private var dots: [NSView] = []
|
||||
private var smoothedLevels: [Float] = [0, 0, 0, 0, 0]
|
||||
private var decayTimer: Timer?
|
||||
private var rawLevel: Float = 0
|
||||
|
||||
private let dotSize: CGFloat = 4
|
||||
private let maxExtra: CGFloat = 28
|
||||
private let spacing: CGFloat = 6
|
||||
private let silenceThreshold: Float = 0.02
|
||||
|
||||
override init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
setupDots()
|
||||
wantsLayer = true
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupDots() {
|
||||
dots.forEach { $0.removeFromSuperview() }
|
||||
dots.removeAll()
|
||||
|
||||
let totalWidth = CGFloat(weights.count) * dotSize + CGFloat(weights.count - 1) * spacing
|
||||
let startX = (bounds.width - totalWidth) / 2
|
||||
|
||||
for (index, _) in weights.enumerated() {
|
||||
let x = startX + CGFloat(index) * (dotSize + spacing)
|
||||
let dot = NSView(frame: NSRect(x: x, y: 0, width: dotSize, height: dotSize))
|
||||
dot.wantsLayer = true
|
||||
dot.layer?.cornerRadius = dotSize / 2
|
||||
dot.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.9).cgColor
|
||||
addSubview(dot)
|
||||
dots.append(dot)
|
||||
}
|
||||
|
||||
decayTimer?.invalidate()
|
||||
decayTimer = Timer.scheduledTimer(withTimeInterval: 0.04, repeats: true) { [weak self] _ in
|
||||
self?.updateDecay()
|
||||
}
|
||||
}
|
||||
|
||||
func updateLevel(_ level: Float) {
|
||||
rawLevel = level
|
||||
for i in 0..<weights.count {
|
||||
let target = level * weights[i]
|
||||
if target > smoothedLevels[i] {
|
||||
smoothedLevels[i] = smoothedLevels[i] * 0.55 + target * 0.45
|
||||
}
|
||||
}
|
||||
applyLevels()
|
||||
}
|
||||
|
||||
private func updateDecay() {
|
||||
for i in 0..<weights.count {
|
||||
let target = rawLevel * weights[i]
|
||||
if target < smoothedLevels[i] {
|
||||
smoothedLevels[i] = smoothedLevels[i] * 0.82 + target * 0.18
|
||||
}
|
||||
if smoothedLevels[i] < silenceThreshold {
|
||||
smoothedLevels[i] = 0
|
||||
}
|
||||
}
|
||||
applyLevels()
|
||||
}
|
||||
|
||||
private func applyLevels() {
|
||||
CATransaction.begin()
|
||||
CATransaction.setAnimationDuration(0.06) // 快速平滑过渡
|
||||
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
|
||||
|
||||
for (i, dot) in dots.enumerated() {
|
||||
let level = smoothedLevels[i]
|
||||
let extra = CGFloat(level) * maxExtra
|
||||
let height = dotSize + extra
|
||||
let y = (bounds.height - height) / 2
|
||||
|
||||
dot.frame = NSRect(x: dot.frame.origin.x, y: y, width: dotSize, height: height)
|
||||
dot.layer?.cornerRadius = min(dotSize, height) / 2
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
setupDots()
|
||||
}
|
||||
|
||||
deinit {
|
||||
decayTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
70
Sources/Utils/Config.swift
Normal file
70
Sources/Utils/Config.swift
Normal file
@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
|
||||
enum RecognitionLanguage: String, CaseIterable {
|
||||
case chineseSimplified = "zh-CN"
|
||||
case english = "en-US"
|
||||
case chineseTraditional = "zh-TW"
|
||||
case japanese = "ja-JP"
|
||||
case korean = "ko-KR"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .chineseSimplified: return "简体中文"
|
||||
case .english: return "English"
|
||||
case .chineseTraditional: return "繁體中文"
|
||||
case .japanese: return "日本語"
|
||||
case .korean: return "한국어"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Config {
|
||||
static var shared = Config()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
|
||||
private enum Keys {
|
||||
static let language = "recognition_language"
|
||||
static let llmEnabled = "llm_enabled"
|
||||
static let llmBaseURL = "llm_base_url"
|
||||
static let llmAPIKey = "llm_api_key"
|
||||
static let llmModel = "llm_model"
|
||||
}
|
||||
|
||||
var language: RecognitionLanguage {
|
||||
get {
|
||||
guard let raw = defaults.string(forKey: Keys.language),
|
||||
let lang = RecognitionLanguage(rawValue: raw) else {
|
||||
return .chineseSimplified
|
||||
}
|
||||
return lang
|
||||
}
|
||||
set {
|
||||
defaults.set(newValue.rawValue, forKey: Keys.language)
|
||||
}
|
||||
}
|
||||
|
||||
var llmEnabled: Bool {
|
||||
get { defaults.bool(forKey: Keys.llmEnabled) }
|
||||
set { defaults.set(newValue, forKey: Keys.llmEnabled) }
|
||||
}
|
||||
|
||||
var llmBaseURL: String {
|
||||
get { defaults.string(forKey: Keys.llmBaseURL) ?? "https://api.deepseek.com" }
|
||||
set { defaults.set(newValue, forKey: Keys.llmBaseURL) }
|
||||
}
|
||||
|
||||
var llmAPIKey: String {
|
||||
get { defaults.string(forKey: Keys.llmAPIKey) ?? "sk-3457fbc33f0b4aefb2ce1d3101bb2341" }
|
||||
set { defaults.set(newValue, forKey: Keys.llmAPIKey) }
|
||||
}
|
||||
|
||||
var llmModel: String {
|
||||
get { defaults.string(forKey: Keys.llmModel) ?? "deepseek-v4-flash" }
|
||||
set { defaults.set(newValue, forKey: Keys.llmModel) }
|
||||
}
|
||||
|
||||
var isLLMConfigured: Bool {
|
||||
!llmBaseURL.isEmpty && !llmAPIKey.isEmpty && !llmModel.isEmpty
|
||||
}
|
||||
}
|
||||
8
Sources/main.swift
Normal file
8
Sources/main.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Cocoa
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
33
scripts/create_app_bundle.sh
Executable file
33
scripts/create_app_bundle.sh
Executable file
@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# create_app_bundle.sh - 将 SPM 构建产物打包为 .app bundle
|
||||
# Usage: ./scripts/create_app_bundle.sh [debug|release]
|
||||
|
||||
set -e
|
||||
|
||||
BUILD_MODE="${1:-debug}"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUILD_DIR="$PROJECT_DIR/.build"
|
||||
BUNDLE_DIR="$BUILD_DIR/$BUILD_MODE/VoiceInput.app"
|
||||
EXEC_SRC="$BUILD_DIR/$BUILD_MODE/VoiceInput"
|
||||
CONTENTS_DIR="$BUNDLE_DIR/Contents"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
|
||||
echo "📦 Creating app bundle at $BUNDLE_DIR..."
|
||||
|
||||
# 清空并创建目录结构
|
||||
rm -rf "$BUNDLE_DIR"
|
||||
mkdir -p "$MACOS_DIR"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
|
||||
# 复制可执行文件
|
||||
cp "$EXEC_SRC" "$MACOS_DIR/VoiceInput"
|
||||
chmod +x "$MACOS_DIR/VoiceInput"
|
||||
|
||||
# 复制 Info.plist
|
||||
cp "$PROJECT_DIR/Sources/Resources/Info.plist" "$CONTENTS_DIR/Info.plist"
|
||||
|
||||
# 创建 PkgInfo
|
||||
echo -n 'APPL????' > "$CONTENTS_DIR/PkgInfo"
|
||||
|
||||
echo "✅ App bundle created: $BUNDLE_DIR"
|
||||
Loading…
Reference in New Issue
Block a user