Files
openless/Sources/OpenLessInsertion/TextInserter.swift
T

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