96 lines
3.8 KiB
Swift
96 lines
3.8 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import OpenLessCore
|
|
|
|
public enum InsertResult: Sendable {
|
|
case inserted
|
|
case copiedFallback(reason: String)
|
|
}
|
|
|
|
public final class TextInserter: @unchecked Sendable {
|
|
public init() {}
|
|
|
|
@MainActor
|
|
public func insert(_ text: String) async -> InsertResult {
|
|
guard !text.isEmpty else { return .copiedFallback(reason: "empty text") }
|
|
|
|
// 不论后续走 AX 还是模拟粘贴,先把剪贴板就绪:
|
|
// - AX 声称成功但实际没插入(Electron / Web textarea 常见)时,用户能 Cmd+V 兜底
|
|
// - 模拟粘贴本身就需要剪贴板里有内容
|
|
copyToClipboard(text)
|
|
|
|
// 策略 1:通过 AX 找到当前 focused element 直接写文本(macOS 原生输入框成功率高)
|
|
if let result = tryAXInsert(text) {
|
|
return result
|
|
}
|
|
|
|
// 策略 2:模拟 Cmd+V(覆盖 Electron/网页/IM 等没实现 AX 写入的场景)
|
|
if simulatePaste() {
|
|
return .inserted
|
|
}
|
|
|
|
return .copiedFallback(reason: "AX + 模拟粘贴均失败;文本已复制,请 ⌘V 手动粘贴")
|
|
}
|
|
|
|
// MARK: - AX
|
|
|
|
private func tryAXInsert(_ text: String) -> InsertResult? {
|
|
let systemWide = AXUIElementCreateSystemWide()
|
|
var focused: AnyObject?
|
|
let err = AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &focused)
|
|
guard err == .success, let element = focused else { return nil }
|
|
// swiftlint:disable:next force_cast
|
|
let elem = element as! AXUIElement
|
|
|
|
var roleObj: AnyObject?
|
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = roleObj as? String ?? ""
|
|
|
|
// 仅在 textfield / textarea 上尝试 AXValue 写入
|
|
if role == kAXTextFieldRole as String || role == kAXTextAreaRole as String {
|
|
// 先尝试在 selection 处插入
|
|
var rangeObj: AnyObject?
|
|
let rangeErr = AXUIElementCopyAttributeValue(elem, kAXSelectedTextRangeAttribute as CFString, &rangeObj)
|
|
if rangeErr == .success, let _ = rangeObj {
|
|
let setErr = AXUIElementSetAttributeValue(elem, kAXSelectedTextAttribute as CFString, text as CFTypeRef)
|
|
if setErr == .success {
|
|
return .inserted
|
|
}
|
|
}
|
|
// 否则覆盖整个 value
|
|
var valueObj: AnyObject?
|
|
AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valueObj)
|
|
let existing = valueObj as? String ?? ""
|
|
let combined = existing + text
|
|
let setErr = AXUIElementSetAttributeValue(elem, kAXValueAttribute as CFString, combined as CFTypeRef)
|
|
if setErr == .success {
|
|
return .inserted
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Clipboard + simulated paste
|
|
|
|
private func copyToClipboard(_ text: String) {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
}
|
|
|
|
private func simulatePaste() -> Bool {
|
|
// 用 .hidSystemState + .cghidEventTap:让 Cmd+V 看起来像真实硬件键盘,
|
|
// 不容易被 Electron / 网页输入框忽略;之前用 .combinedSessionState +
|
|
// .cgAnnotatedSessionEventTap 在某些 app 上不触发粘贴。
|
|
let source = CGEventSource(stateID: .hidSystemState)
|
|
guard let down = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: true), // V key
|
|
let up = CGEvent(keyboardEventSource: source, virtualKey: 9, keyDown: false) else {
|
|
return false
|
|
}
|
|
down.flags = .maskCommand
|
|
up.flags = .maskCommand
|
|
down.post(tap: .cghidEventTap)
|
|
up.post(tap: .cghidEventTap)
|
|
return true
|
|
}
|
|
}
|