Use Accessibility API's for copying selected text and use copy action as a fallback
This commit is contained in:
parent
b65b97073d
commit
5bdbb39ac8
@ -1,15 +1,83 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
class SelectedTextService {
|
import ApplicationServices
|
||||||
// Private pasteboard type to avoid clipboard history pollution
|
|
||||||
private static let privatePasteboardType = NSPasteboard.PasteboardType("com.prakashjoshipax.VoiceInk.transient")
|
|
||||||
|
|
||||||
|
class SelectedTextService {
|
||||||
static func fetchSelectedText() -> String? {
|
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 pasteboard = NSPasteboard.general
|
||||||
let originalClipboardText = pasteboard.string(forType: .string)
|
let originalClipboardText = pasteboard.string(forType: .string)
|
||||||
|
|
||||||
// Save original clipboard content (all UTIs with their data)
|
|
||||||
let originalPasteboardItems = pasteboard.pasteboardItems?.map { item in
|
let originalPasteboardItems = pasteboard.pasteboardItems?.map { item in
|
||||||
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { acc, type in
|
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { acc, type in
|
||||||
if let data = item.data(forType: type) {
|
if let data = item.data(forType: type) {
|
||||||
@ -18,34 +86,35 @@ class SelectedTextService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer {
|
||||||
|
restorePasteboard(
|
||||||
|
pasteboard,
|
||||||
|
items: originalPasteboardItems,
|
||||||
|
string: originalClipboardText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Clear clipboard to prepare for selection detection
|
// Clear clipboard to prepare for selection detection
|
||||||
pasteboard.clearContents()
|
pasteboard.clearContents()
|
||||||
|
|
||||||
// Simulate Cmd+C to copy any selected text
|
|
||||||
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)
|
guard copyAction() else { return nil }
|
||||||
cDown?.post(tap: .cghidEventTap)
|
|
||||||
cUp?.post(tap: .cghidEventTap)
|
|
||||||
cmdUp?.post(tap: .cghidEventTap)
|
|
||||||
|
|
||||||
// Wait for copy operation to complete
|
// Wait for copy operation to complete
|
||||||
Thread.sleep(forTimeInterval: 0.1)
|
Thread.sleep(forTimeInterval: 0.1)
|
||||||
|
|
||||||
// Read the copied text
|
// Read the copied text
|
||||||
let selectedText = pasteboard.string(forType: .string)
|
return pasteboard.string(forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
// Restore original clipboard content
|
private static func restorePasteboard(
|
||||||
|
_ pasteboard: NSPasteboard,
|
||||||
|
items: [[NSPasteboard.PasteboardType: Data]]?,
|
||||||
|
string: String?
|
||||||
|
) {
|
||||||
pasteboard.clearContents()
|
pasteboard.clearContents()
|
||||||
if let originalItems = originalPasteboardItems, !originalItems.isEmpty {
|
|
||||||
let restoredItems: [NSPasteboardItem] = originalItems.compactMap { dataMap in
|
if let items, !items.isEmpty {
|
||||||
|
let restoredItems: [NSPasteboardItem] = items.compactMap { dataMap in
|
||||||
guard !dataMap.isEmpty else { return nil }
|
guard !dataMap.isEmpty else { return nil }
|
||||||
let item = NSPasteboardItem()
|
let item = NSPasteboardItem()
|
||||||
for (type, data) in dataMap {
|
for (type, data) in dataMap {
|
||||||
@ -53,15 +122,65 @@ class SelectedTextService {
|
|||||||
}
|
}
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
if !restoredItems.isEmpty {
|
if !restoredItems.isEmpty {
|
||||||
pasteboard.writeObjects(restoredItems)
|
pasteboard.writeObjects(restoredItems)
|
||||||
} else if let originalClipboardText {
|
return
|
||||||
_ = pasteboard.setString(originalClipboardText, forType: .string)
|
|
||||||
}
|
}
|
||||||
} else if let originalClipboardText {
|
|
||||||
_ = pasteboard.setString(originalClipboardText, forType: .string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return selectedText
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user