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