- CGEvent cghidEventTap 全局监听 Fn 键 - Apple Speech 流式语音识别(zh-CN 默认) - 实时音频 RMS 电平驱动波形动画 - NSPanel 胶囊浮动窗口 - 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换 - OpenAI 兼容 LLM 纠错(DeepSeek 预设) - LSUIElement 菜单栏运行 - SPM + Makefile 构建
106 lines
3.3 KiB
Swift
106 lines
3.3 KiB
Swift
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)
|
||
}
|
||
}
|