vOOice/VoiceInk/Services/SelectedTextService.swift

187 lines
5.6 KiB
Swift

import Foundation
import AppKit
import ApplicationServices
class SelectedTextService {
static func fetchSelectedText() -> String? {
guard ensureAccessibilityPermission() else {
return nil
}
let strategies: [() -> String?] = [
getSelectedTextViaAccessibility,
getSelectedTextViaKeyboardCopy
]
for fetch in strategies {
if let text = fetch(), !text.isEmpty {
return text
}
}
return nil
}
private static func getSelectedTextViaAccessibility() -> String? {
let systemWideElement = AXUIElementCreateSystemWide()
var focusedElement: AnyObject?
let focusedResult = AXUIElementCopyAttributeValue(
systemWideElement,
kAXFocusedUIElementAttribute as CFString,
&focusedElement
)
guard focusedResult == .success, let focusedElement else {
return nil
}
let element = focusedElement as! AXUIElement
if let selectedText = stringAttribute(kAXSelectedTextAttribute as CFString, of: element) {
return selectedText
}
guard
let selectedRange = rangeAttribute(kAXSelectedTextRangeAttribute as CFString, of: element),
let value = stringAttribute(kAXValueAttribute as CFString, of: element),
let textRange = Range(selectedRange, in: value)
else {
return nil
}
let substring = String(value[textRange])
return substring.isEmpty ? nil : substring
}
private static func getSelectedTextViaKeyboardCopy() -> String? {
return readSelectedTextUsingCopyAction {
let source = CGEventSource(stateID: .hidSystemState)
let cmdDown = CGEvent(keyboardEventSource: source, virtualKey: 0x37, keyDown: true)
cmdDown?.flags = .maskCommand
let cDown = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: true)
cDown?.flags = .maskCommand
let cUp = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: false)
cUp?.flags = .maskCommand
let cmdUp = CGEvent(keyboardEventSource: source, virtualKey: 0x37, keyDown: false)
cmdDown?.post(tap: .cghidEventTap)
cDown?.post(tap: .cghidEventTap)
cUp?.post(tap: .cghidEventTap)
cmdUp?.post(tap: .cghidEventTap)
return true
}
}
private static func readSelectedTextUsingCopyAction(_ copyAction: () -> Bool) -> String? {
let pasteboard = NSPasteboard.general
let originalClipboardText = pasteboard.string(forType: .string)
let originalPasteboardItems = pasteboard.pasteboardItems?.map { item in
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { acc, type in
if let data = item.data(forType: type) {
acc[type] = data
}
}
}
defer {
restorePasteboard(
pasteboard,
items: originalPasteboardItems,
string: originalClipboardText
)
}
// Clear clipboard to prepare for selection detection
pasteboard.clearContents()
guard copyAction() else { return nil }
// Wait for copy operation to complete
Thread.sleep(forTimeInterval: 0.1)
// Read the copied text
return pasteboard.string(forType: .string)
}
private static func restorePasteboard(
_ pasteboard: NSPasteboard,
items: [[NSPasteboard.PasteboardType: Data]]?,
string: String?
) {
pasteboard.clearContents()
if let items, !items.isEmpty {
let restoredItems: [NSPasteboardItem] = items.compactMap { dataMap in
guard !dataMap.isEmpty else { return nil }
let item = NSPasteboardItem()
for (type, data) in dataMap {
item.setData(data, forType: type)
}
return item
}
if !restoredItems.isEmpty {
pasteboard.writeObjects(restoredItems)
return
}
}
if let string {
_ = pasteboard.setString(string, forType: .string)
}
}
private static func stringAttribute(_ attribute: CFString, of element: AXUIElement) -> String? {
var value: AnyObject?
let result = AXUIElementCopyAttributeValue(element, attribute, &value)
guard result == .success, let value else {
return nil
}
if let text = value as? String {
return text
}
if let attributed = value as? NSAttributedString {
return attributed.string
}
return nil
}
private static func rangeAttribute(_ attribute: CFString, of element: AXUIElement) -> NSRange? {
var value: AnyObject?
let result = AXUIElementCopyAttributeValue(element, attribute, &value)
guard result == .success, let value else {
return nil
}
guard CFGetTypeID(value) == AXValueGetTypeID() else {
return nil
}
let rangeValue = value as! AXValue
guard AXValueGetType(rangeValue) == .cfRange else {
return nil
}
var range = CFRange()
let success = AXValueGetValue(rangeValue, .cfRange, &range)
guard success else {
return nil
}
return NSRange(location: range.location, length: range.length)
}
private static func ensureAccessibilityPermission() -> Bool {
AXIsProcessTrustedWithOptions(nil)
}
}