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:
JOJO 2026-04-24 16:17:57 +08:00
commit 1d9c9d5fa0
18 changed files with 1713 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.build/
.swiftpm/
*.app

84
Makefile Normal file
View 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
View 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")
]
)
]
)

View 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()
}
}

View 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)
}
}

View 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)
}
}
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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)")
}
}

View 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)
}
}

View 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>

View 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
}
}

View 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 = ""
}
}
}

View 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()
}
}

View 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
View 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
View 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"