209 lines
8.9 KiB
Swift
209 lines
8.9 KiB
Swift
import AppKit
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
/// 把文字写进当前焦点输入框。
|
|
/// 默认通过 Unicode 键盘事件直接输入,不占用剪贴板。
|
|
enum Inserter {
|
|
enum Method: String {
|
|
case accessibility = "AX"
|
|
case paste = "⌘V"
|
|
case keyboard = "直接输入"
|
|
case retained = "保留结果"
|
|
}
|
|
|
|
struct Outcome {
|
|
var method: Method
|
|
var ok: Bool
|
|
}
|
|
|
|
static func insert(_ text: String, method: InsertMethod, expectedPID: pid_t? = nil) -> Outcome {
|
|
guard !text.isEmpty else { return Outcome(method: .accessibility, ok: false) }
|
|
|
|
if isSecureEventInputEnabled() || (expectedPID != nil && NSWorkspace.shared.frontmostApplication?.processIdentifier != expectedPID) {
|
|
return Outcome(method: .retained, ok: false)
|
|
}
|
|
|
|
switch method {
|
|
case .axOnly:
|
|
let result = insertViaAX(text)
|
|
return Outcome(method: .accessibility, ok: result == .inserted)
|
|
case .paste:
|
|
paste(text)
|
|
return Outcome(method: .paste, ok: true)
|
|
case .auto:
|
|
return Outcome(method: .keyboard, ok: typeText(text, expectedPID: expectedPID))
|
|
}
|
|
}
|
|
|
|
private enum AXResult { case inserted, unavailable, uncertain }
|
|
|
|
// MARK: - AX
|
|
|
|
static func focusedElement() -> AXUIElement? {
|
|
let system = AXUIElementCreateSystemWide()
|
|
var value: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute as CFString, &value) == .success,
|
|
let value, CFGetTypeID(value) == AXUIElementGetTypeID() else {
|
|
return nil
|
|
}
|
|
return (value as! AXUIElement)
|
|
}
|
|
|
|
private static func insertViaAX(_ text: String) -> AXResult {
|
|
guard AXIsProcessTrusted(), let element = focusedElement() else { return .unavailable }
|
|
|
|
let beforeValue = stringAttribute(element, kAXValueAttribute)
|
|
var rangeRef: CFTypeRef?
|
|
guard let beforeValue,
|
|
AXUIElementCopyAttributeValue(element, kAXSelectedTextRangeAttribute as CFString, &rangeRef) == .success,
|
|
let rangeRef, CFGetTypeID(rangeRef) == AXValueGetTypeID() else { return .unavailable }
|
|
var range = CFRange()
|
|
guard AXValueGetValue(rangeRef as! AXValue, .cfRange, &range),
|
|
range.location >= 0, range.length >= 0,
|
|
range.location + range.length <= (beforeValue as NSString).length else { return .unavailable }
|
|
let expected = (beforeValue as NSString).replacingCharacters(
|
|
in: NSRange(location: range.location, length: range.length), with: text)
|
|
let result = AXUIElementSetAttributeValue(element, kAXSelectedTextAttribute as CFString, text as CFString)
|
|
guard result == .success else { return .unavailable }
|
|
// A successful AX write must never fall through to paste: that could insert twice.
|
|
guard stringAttribute(element, kAXValueAttribute) == expected else {
|
|
Log.error("AX accepted write but readback differs; avoiding duplicate paste")
|
|
return .uncertain
|
|
}
|
|
return .inserted
|
|
}
|
|
|
|
private static func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
|
|
var value: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
|
let value else { return nil }
|
|
if let str = value as? String { return str }
|
|
if let str = value as? NSString { return str as String }
|
|
return nil
|
|
}
|
|
|
|
private static func intAttribute(_ element: AXUIElement, _ attribute: String) -> Int? {
|
|
var value: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success,
|
|
let value, CFGetTypeID(value) == CFNumberGetTypeID() else { return nil }
|
|
return (value as? NSNumber)?.intValue
|
|
}
|
|
|
|
/// 焦点输入框在屏幕上的位置(AX 坐标系,左上原点),用于 HUD 定位。
|
|
static func focusedFrame() -> CGRect? {
|
|
guard let element = focusedElement() else { return nil }
|
|
var posRef: CFTypeRef?
|
|
var sizeRef: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &posRef) == .success,
|
|
AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success,
|
|
let posRef, let sizeRef,
|
|
CFGetTypeID(posRef) == AXValueGetTypeID(),
|
|
CFGetTypeID(sizeRef) == AXValueGetTypeID() else {
|
|
return nil
|
|
}
|
|
var point = CGPoint.zero
|
|
var size = CGSize.zero
|
|
AXValueGetValue((posRef as! AXValue), .cgPoint, &point)
|
|
AXValueGetValue((sizeRef as! AXValue), .cgSize, &size)
|
|
guard size.width > 1, size.height > 1 else { return nil }
|
|
return CGRect(origin: point, size: size)
|
|
}
|
|
|
|
// MARK: - 直接输入
|
|
|
|
static let syntheticEventMarker: Int64 = 0x54494E59545453
|
|
|
|
/// Keep each Quartz event small without splitting a UTF-16 surrogate pair.
|
|
static func unicodeChunks(_ text: String) -> [[UInt16]] {
|
|
var chunks: [[UInt16]] = []
|
|
var chunk: [UInt16] = []
|
|
for scalar in text.unicodeScalars {
|
|
let units = Array(String(scalar).utf16)
|
|
if chunk.count + units.count > 20 {
|
|
chunks.append(chunk)
|
|
chunk = []
|
|
}
|
|
chunk.append(contentsOf: units)
|
|
}
|
|
if !chunk.isEmpty { chunks.append(chunk) }
|
|
return chunks
|
|
}
|
|
|
|
private static func typeText(_ text: String, expectedPID: pid_t?) -> Bool {
|
|
guard AXIsProcessTrusted(),
|
|
let target = NSWorkspace.shared.frontmostApplication?.processIdentifier,
|
|
expectedPID == nil || target == expectedPID,
|
|
let source = CGEventSource(stateID: .privateState) else { return false }
|
|
// Do not inherit a still-held Option key from the end-of-dictation hotkey.
|
|
// Posting to the captured app also avoids rerouting queued input elsewhere.
|
|
for chunk in unicodeChunks(text) {
|
|
guard !isSecureEventInputEnabled(),
|
|
NSWorkspace.shared.frontmostApplication?.processIdentifier == target,
|
|
let down = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true),
|
|
let up = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false) else { return false }
|
|
for event in [down, up] {
|
|
event.flags = []
|
|
event.setIntegerValueField(.eventSourceUserData, value: syntheticEventMarker)
|
|
chunk.withUnsafeBufferPointer { units in
|
|
event.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units.baseAddress)
|
|
}
|
|
event.postToPid(target)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - 剪贴板
|
|
|
|
private static func paste(_ text: String) {
|
|
let pb = NSPasteboard.general
|
|
let targetPID = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
let saved: [[NSPasteboard.PasteboardType: Data]] = (pb.pasteboardItems ?? []).map { item in
|
|
Dictionary(uniqueKeysWithValues: item.types.compactMap { type in item.data(forType: type).map { (type, $0) } })
|
|
}
|
|
|
|
pb.clearContents()
|
|
pb.setString(text, forType: .string)
|
|
|
|
let writtenChange = pb.changeCount
|
|
|
|
// 让剪贴板写入先落地,再发按键
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
|
guard !isSecureEventInputEnabled(), NSWorkspace.shared.frontmostApplication?.processIdentifier == targetPID else { return }
|
|
let source = CGEventSource(stateID: .combinedSessionState)
|
|
let vKey: CGKeyCode = 9
|
|
let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true)
|
|
down?.flags = .maskCommand
|
|
down?.post(tap: .cghidEventTap)
|
|
let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false)
|
|
up?.flags = .maskCommand
|
|
up?.post(tap: .cghidEventTap)
|
|
|
|
// 恢复用户原本的剪贴板(若没有被改掉)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
if pb.changeCount == writtenChange {
|
|
pb.clearContents()
|
|
let items = saved.map { contents in
|
|
let item = NSPasteboardItem()
|
|
for (type, data) in contents { item.setData(data, forType: type) }
|
|
return item
|
|
}
|
|
pb.writeObjects(items)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 安全输入
|
|
|
|
static func isSecureEventInputEnabled() -> Bool {
|
|
guard let sym = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "IsSecureEventInputEnabled") else {
|
|
return false
|
|
}
|
|
typealias Fn = @convention(c) () -> UInt8
|
|
let fn = unsafeBitCast(sym, to: Fn.self)
|
|
return fn() != 0
|
|
}
|
|
}
|