Files

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