You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
218 lines
8.6 KiB
218 lines
8.6 KiB
import Cocoa
|
|
import ApplicationServices
|
|
|
|
public final class InputBoxManager {
|
|
public static let shared = InputBoxManager()
|
|
|
|
private var targetedElement: AXUIElement?
|
|
private var streamedCharacterCount: Int = 0
|
|
private var didStreamAnyText: Bool = false
|
|
private let lock = NSLock()
|
|
|
|
private init() {}
|
|
|
|
/// Prepares and guarantees that an input box is focused before or at the start of recording
|
|
@discardableResult
|
|
public func ensureFocusedInputBox() -> AXUIElement? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
|
|
streamedCharacterCount = 0
|
|
didStreamAnyText = false
|
|
targetedElement = nil
|
|
|
|
guard AXIsProcessTrusted() else {
|
|
print("InputBoxManager: AX not trusted, falling back to general keyboard events")
|
|
return nil
|
|
}
|
|
|
|
guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil }
|
|
let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
|
|
|
|
// 1. Check if an element is already focused in the frontmost application
|
|
var focusedObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
|
|
let focused = focusedObj {
|
|
let elem = (focused as! AXUIElement)
|
|
if isEditableElement(elem) {
|
|
targetedElement = elem
|
|
return elem
|
|
}
|
|
}
|
|
|
|
// 2. Also check systemWide focus
|
|
let sysWide = AXUIElementCreateSystemWide()
|
|
if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
|
|
let focused = focusedObj {
|
|
let elem = (focused as! AXUIElement)
|
|
if isEditableElement(elem) {
|
|
targetedElement = elem
|
|
return elem
|
|
}
|
|
}
|
|
|
|
// 3. If no input is focused, search the active window of the frontmost app for an input field
|
|
var windowObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success,
|
|
let win = windowObj {
|
|
let winElem = (win as! AXUIElement)
|
|
if let bestInput = findBestInputElement(in: winElem) {
|
|
// Focus it via Accessibility
|
|
AXUIElementSetAttributeValue(bestInput, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
|
|
|
// If it has a clickable position, click its center to ensure real input focus
|
|
if let center = getElementCenter(bestInput) {
|
|
clickAtPoint(center)
|
|
}
|
|
|
|
targetedElement = bestInput
|
|
return bestInput
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Stream delta text (incremental words) live into the focused input box
|
|
public func streamDelta(text: String) {
|
|
guard !text.isEmpty else { return }
|
|
|
|
lock.lock()
|
|
didStreamAnyText = true
|
|
streamedCharacterCount += text.count
|
|
let target = targetedElement
|
|
lock.unlock()
|
|
|
|
// 1. Try direct Accessibility insertion if element supports it
|
|
if let elem = target {
|
|
var selectedTextObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(elem, kAXSelectedTextAttribute as CFString, &selectedTextObj) == .success {
|
|
let err = AXUIElementSetAttributeValue(elem, kAXSelectedTextAttribute as CFString, text as CFString)
|
|
if err == .success {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Native Unicode CGEvent typing (universal, works in Telegram, browsers, Electron, native apps)
|
|
typeUnicodeString(text)
|
|
}
|
|
|
|
/// Commits final text to the input box and system clipboard
|
|
public func commitFinalText(processedText: String, addTrailingSpace: Bool) {
|
|
guard !processedText.isEmpty else { return }
|
|
|
|
let textToSave = addTrailingSpace ? (processedText + " ") : processedText
|
|
|
|
// 1. Always put full processed text into clipboard
|
|
let pb = NSPasteboard.general
|
|
pb.clearContents()
|
|
pb.setString(textToSave, forType: .string)
|
|
|
|
lock.lock()
|
|
let hadStreamed = didStreamAnyText
|
|
let streamedCount = streamedCharacterCount
|
|
targetedElement = nil
|
|
lock.unlock()
|
|
|
|
// If we didn't stream live (e.g. command or burst retry), paste the full text now
|
|
if !hadStreamed || streamedCount == 0 {
|
|
pasteClipboardToFrontmostApp()
|
|
}
|
|
}
|
|
|
|
/// Simulates Cmd+V to paste clipboard contents
|
|
public func pasteClipboardToFrontmostApp() {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
|
|
let source = CGEventSource(stateID: .combinedSessionState)
|
|
let vKeyCode: CGKeyCode = 0x09 // 'v'
|
|
if let down = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true),
|
|
let up = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) {
|
|
down.flags = .maskCommand
|
|
up.flags = .maskCommand
|
|
down.post(tap: .cghidEventTap)
|
|
up.post(tap: .cghidEventTap)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func isEditableElement(_ elem: AXUIElement) -> Bool {
|
|
var roleObj: CFTypeRef?
|
|
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = (roleObj as? String) ?? ""
|
|
if role == "AXTextField" || role == "AXTextArea" || role == "AXComboBox" || role == "AXWebArea" {
|
|
return true
|
|
}
|
|
var valObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(elem, kAXValueAttribute as CFString, &valObj) == .success {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func findBestInputElement(in parent: AXUIElement, maxDepth: Int = 8, depth: Int = 0) -> AXUIElement? {
|
|
if depth > maxDepth { return nil }
|
|
|
|
var roleObj: CFTypeRef?
|
|
AXUIElementCopyAttributeValue(parent, kAXRoleAttribute as CFString, &roleObj)
|
|
let role = (roleObj as? String) ?? ""
|
|
|
|
if role == "AXTextField" || role == "AXTextArea" || role == "AXComboBox" {
|
|
return parent
|
|
}
|
|
|
|
var childrenObj: CFTypeRef?
|
|
if AXUIElementCopyAttributeValue(parent, kAXChildrenAttribute as CFString, &childrenObj) == .success,
|
|
let children = childrenObj as? [AXUIElement] {
|
|
for child in children {
|
|
if let found = findBestInputElement(in: child, maxDepth: maxDepth, depth: depth + 1) {
|
|
return found
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func getElementCenter(_ elem: AXUIElement) -> CGPoint? {
|
|
var posVal: CFTypeRef?
|
|
var sizeVal: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(elem, kAXPositionAttribute as CFString, &posVal) == .success,
|
|
AXUIElementCopyAttributeValue(elem, kAXSizeAttribute as CFString, &sizeVal) == .success else {
|
|
return nil
|
|
}
|
|
var pt = CGPoint.zero
|
|
var sz = CGSize.zero
|
|
guard AXValueGetValue(posVal as! AXValue, .cgPoint, &pt),
|
|
AXValueGetValue(sizeVal as! AXValue, .cgSize, &sz) else {
|
|
return nil
|
|
}
|
|
return CGPoint(x: pt.x + sz.width / 2.0, y: pt.y + sz.height / 2.0)
|
|
}
|
|
|
|
private func clickAtPoint(_ point: CGPoint) {
|
|
let source = CGEventSource(stateID: .hidSystemState)
|
|
if let clickDown = CGEvent(mouseEventSource: source, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
|
|
let clickUp = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) {
|
|
clickDown.post(tap: .cghidEventTap)
|
|
clickUp.post(tap: .cghidEventTap)
|
|
}
|
|
}
|
|
|
|
private func typeUnicodeString(_ string: String) {
|
|
let source = CGEventSource(stateID: .hidSystemState)
|
|
let utf16 = Array(string.utf16)
|
|
|
|
guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true),
|
|
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false) else {
|
|
return
|
|
}
|
|
|
|
keyDown.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: utf16)
|
|
keyUp.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: utf16)
|
|
|
|
keyDown.post(tap: .cghidEventTap)
|
|
keyUp.post(tap: .cghidEventTap)
|
|
}
|
|
}
|