feat: Integrate SelectedTextKit for robust text retrieval with multi-strategy support
This commit is contained in:
parent
5bdbb39ac8
commit
357804c03d
@ -97,6 +97,7 @@ If you encounter any issues or have questions, please:
|
|||||||
- [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Launch at login functionality
|
- [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Launch at login functionality
|
||||||
- [MediaRemoteAdapter](https://github.com/ejbills/mediaremote-adapter) - Media playback control during recording
|
- [MediaRemoteAdapter](https://github.com/ejbills/mediaremote-adapter) - Media playback control during recording
|
||||||
- [Zip](https://github.com/marmelroy/Zip) - File compression and decompression utilities
|
- [Zip](https://github.com/marmelroy/Zip) - File compression and decompression utilities
|
||||||
|
- [SelectedTextKit](https://github.com/tisfeng/SelectedTextKit) - A modern macOS library for getting selected text
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E1A261112CC143AC00B233D1 /* KeyboardShortcuts */; };
|
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E1A261112CC143AC00B233D1 /* KeyboardShortcuts */; };
|
||||||
E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; };
|
E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; };
|
||||||
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD45E2CC544F100303ECB /* Sparkle */; };
|
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD45E2CC544F100303ECB /* Sparkle */; };
|
||||||
|
E1BBBDCC2EB0DF6700C3ABFE /* SelectedTextKit in Frameworks */ = {isa = PBXBuildFile; productRef = E1BBBDCB2EB0DF6700C3ABFE /* SelectedTextKit */; };
|
||||||
E1D7EF992E35E16C00640029 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */; };
|
E1D7EF992E35E16C00640029 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */; };
|
||||||
E1D7EF9A2E35E19B00640029 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
E1D7EF9A2E35E19B00640029 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||||
E1ECEC162E44591300DFFBA8 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = E1ECEC152E44591300DFFBA8 /* Zip */; };
|
E1ECEC162E44591300DFFBA8 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = E1ECEC152E44591300DFFBA8 /* Zip */; };
|
||||||
@ -86,6 +87,7 @@
|
|||||||
E11BBA4E2E5DC555000AB839 /* FluidAudio in Frameworks */,
|
E11BBA4E2E5DC555000AB839 /* FluidAudio in Frameworks */,
|
||||||
E1D7EF992E35E16C00640029 /* MediaRemoteAdapter in Frameworks */,
|
E1D7EF992E35E16C00640029 /* MediaRemoteAdapter in Frameworks */,
|
||||||
E17382402E4C7D0E001BAEBE /* whisper.xcframework in Frameworks */,
|
E17382402E4C7D0E001BAEBE /* whisper.xcframework in Frameworks */,
|
||||||
|
E1BBBDCC2EB0DF6700C3ABFE /* SelectedTextKit in Frameworks */,
|
||||||
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */,
|
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */,
|
||||||
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */,
|
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */,
|
||||||
);
|
);
|
||||||
@ -165,6 +167,7 @@
|
|||||||
E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */,
|
E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */,
|
||||||
E1ECEC152E44591300DFFBA8 /* Zip */,
|
E1ECEC152E44591300DFFBA8 /* Zip */,
|
||||||
E11BBA4D2E5DC555000AB839 /* FluidAudio */,
|
E11BBA4D2E5DC555000AB839 /* FluidAudio */,
|
||||||
|
E1BBBDCB2EB0DF6700C3ABFE /* SelectedTextKit */,
|
||||||
);
|
);
|
||||||
productName = VoiceInk;
|
productName = VoiceInk;
|
||||||
productReference = E11473B02CBE0F0A00318EE4 /* VoiceInk.app */;
|
productReference = E11473B02CBE0F0A00318EE4 /* VoiceInk.app */;
|
||||||
@ -255,6 +258,7 @@
|
|||||||
E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */,
|
E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */,
|
||||||
E1ECEC142E44590200DFFBA8 /* XCRemoteSwiftPackageReference "Zip" */,
|
E1ECEC142E44590200DFFBA8 /* XCRemoteSwiftPackageReference "Zip" */,
|
||||||
E11BBA4C2E5DC555000AB839 /* XCRemoteSwiftPackageReference "FluidAudio" */,
|
E11BBA4C2E5DC555000AB839 /* XCRemoteSwiftPackageReference "FluidAudio" */,
|
||||||
|
E1BBBDCA2EB0DF6700C3ABFE /* XCRemoteSwiftPackageReference "SelectedTextKit" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = E11473B12CBE0F0A00318EE4 /* Products */;
|
productRefGroup = E11473B12CBE0F0A00318EE4 /* Products */;
|
||||||
@ -659,6 +663,14 @@
|
|||||||
minimumVersion = 2.6.4;
|
minimumVersion = 2.6.4;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
E1BBBDCA2EB0DF6700C3ABFE /* XCRemoteSwiftPackageReference "SelectedTextKit" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/tisfeng/SelectedTextKit";
|
||||||
|
requirement = {
|
||||||
|
kind = revision;
|
||||||
|
revision = f6c1574ad6f747e1110b544c35e6ec64ed6f52f1;
|
||||||
|
};
|
||||||
|
};
|
||||||
E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */ = {
|
E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/ejbills/mediaremote-adapter";
|
repositoryURL = "https://github.com/ejbills/mediaremote-adapter";
|
||||||
@ -698,6 +710,11 @@
|
|||||||
package = E1ADD45D2CC544F100303ECB /* XCRemoteSwiftPackageReference "Sparkle" */;
|
package = E1ADD45D2CC544F100303ECB /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||||
productName = Sparkle;
|
productName = Sparkle;
|
||||||
};
|
};
|
||||||
|
E1BBBDCB2EB0DF6700C3ABFE /* SelectedTextKit */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E1BBBDCA2EB0DF6700C3ABFE /* XCRemoteSwiftPackageReference "SelectedTextKit" */;
|
||||||
|
productName = SelectedTextKit;
|
||||||
|
};
|
||||||
E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */ = {
|
E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */;
|
package = E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */;
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "0b9379abd19d2f53581c233273d09235e935a8d2b1180cf253dd69baa2784b39",
|
"originHash" : "53977eb7bc5f27d05d8be33f99b4c824929a33b2f189fcc8c4c2a6662bcd8075",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "axswift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/clavierorg/AXSwift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2efa201fa18184de98674165592fd1ecb0c00a6d",
|
||||||
|
"version" : "0.3.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "fluidaudio",
|
"identity" : "fluidaudio",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -19,6 +28,15 @@
|
|||||||
"version" : "2.4.0"
|
"version" : "2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "keysender",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/jordanbaird/KeySender",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "99584bf1a03ab600cc44be89a2dcd98b2dbeb9de",
|
||||||
|
"version" : "0.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "launchatlogin-modern",
|
"identity" : "launchatlogin-modern",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -37,6 +55,14 @@
|
|||||||
"revision" : "3529aa25023082a2ceadebcd2c9c4a9430ee96b9"
|
"revision" : "3529aa25023082a2ceadebcd2c9c4a9430ee96b9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "selectedtextkit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/tisfeng/SelectedTextKit",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f6c1574ad6f747e1110b544c35e6ec64ed6f52f1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "sparkle",
|
"identity" : "sparkle",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -139,8 +139,8 @@ class AIEnhancementService: ObservableObject {
|
|||||||
lastRequestTime = Date()
|
lastRequestTime = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getSystemMessage(for mode: EnhancementPrompt) -> String {
|
private func getSystemMessage(for mode: EnhancementPrompt) async -> String {
|
||||||
let selectedText = SelectedTextService.fetchSelectedText()
|
let selectedText = await SelectedTextService.fetchSelectedText()
|
||||||
|
|
||||||
let selectedTextContext = if let selectedText = selectedText, !selectedText.isEmpty {
|
let selectedTextContext = if let selectedText = selectedText, !selectedText.isEmpty {
|
||||||
"\n\n<CURRENTLY_SELECTED_TEXT>\n\(selectedText)\n</CURRENTLY_SELECTED_TEXT>"
|
"\n\n<CURRENTLY_SELECTED_TEXT>\n\(selectedText)\n</CURRENTLY_SELECTED_TEXT>"
|
||||||
@ -198,7 +198,7 @@ class AIEnhancementService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let formattedText = "\n<TRANSCRIPT>\n\(text)\n</TRANSCRIPT>"
|
let formattedText = "\n<TRANSCRIPT>\n\(text)\n</TRANSCRIPT>"
|
||||||
let systemMessage = getSystemMessage(for: mode)
|
let systemMessage = await getSystemMessage(for: mode)
|
||||||
|
|
||||||
// Persist the exact payload being sent (also used for UI)
|
// Persist the exact payload being sent (also used for UI)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
@ -1,186 +1,16 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
import ApplicationServices
|
import SelectedTextKit
|
||||||
|
|
||||||
class SelectedTextService {
|
class SelectedTextService {
|
||||||
static func fetchSelectedText() -> String? {
|
static func fetchSelectedText() async -> String? {
|
||||||
guard ensureAccessibilityPermission() else {
|
let strategies: [TextStrategy] = [.accessibility, .menuAction, .shortcut]
|
||||||
return nil
|
do {
|
||||||
}
|
let selectedText = try await SelectedTextManager.shared.getSelectedText(strategies: strategies)
|
||||||
|
|
||||||
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
|
return selectedText
|
||||||
}
|
} catch {
|
||||||
|
print("Failed to get selected text: \(error)")
|
||||||
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
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user