diff --git a/README.md b/README.md
index 8aedc8f..e328022 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,7 @@ If you encounter any issues or have questions, please:
- [LaunchAtLogin](https://github.com/sindresorhus/LaunchAtLogin) - Launch at login functionality
- [MediaRemoteAdapter](https://github.com/ejbills/mediaremote-adapter) - Media playback control during recording
- [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
---
diff --git a/VoiceInk.xcodeproj/project.pbxproj b/VoiceInk.xcodeproj/project.pbxproj
index ffdeed1..2994b69 100644
--- a/VoiceInk.xcodeproj/project.pbxproj
+++ b/VoiceInk.xcodeproj/project.pbxproj
@@ -13,6 +13,7 @@
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = E1A261112CC143AC00B233D1 /* KeyboardShortcuts */; };
E1ADD45A2CC5352A00303ECB /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = E1ADD4592CC5352A00303ECB /* LaunchAtLogin */; };
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 */; };
E1D7EF9A2E35E19B00640029 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
E1ECEC162E44591300DFFBA8 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = E1ECEC152E44591300DFFBA8 /* Zip */; };
@@ -86,6 +87,7 @@
E11BBA4E2E5DC555000AB839 /* FluidAudio in Frameworks */,
E1D7EF992E35E16C00640029 /* MediaRemoteAdapter in Frameworks */,
E17382402E4C7D0E001BAEBE /* whisper.xcframework in Frameworks */,
+ E1BBBDCC2EB0DF6700C3ABFE /* SelectedTextKit in Frameworks */,
E1ADD45F2CC544F100303ECB /* Sparkle in Frameworks */,
E1A261122CC143AC00B233D1 /* KeyboardShortcuts in Frameworks */,
);
@@ -165,6 +167,7 @@
E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */,
E1ECEC152E44591300DFFBA8 /* Zip */,
E11BBA4D2E5DC555000AB839 /* FluidAudio */,
+ E1BBBDCB2EB0DF6700C3ABFE /* SelectedTextKit */,
);
productName = VoiceInk;
productReference = E11473B02CBE0F0A00318EE4 /* VoiceInk.app */;
@@ -255,6 +258,7 @@
E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */,
E1ECEC142E44590200DFFBA8 /* XCRemoteSwiftPackageReference "Zip" */,
E11BBA4C2E5DC555000AB839 /* XCRemoteSwiftPackageReference "FluidAudio" */,
+ E1BBBDCA2EB0DF6700C3ABFE /* XCRemoteSwiftPackageReference "SelectedTextKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = E11473B12CBE0F0A00318EE4 /* Products */;
@@ -659,6 +663,14 @@
minimumVersion = 2.6.4;
};
};
+ E1BBBDCA2EB0DF6700C3ABFE /* XCRemoteSwiftPackageReference "SelectedTextKit" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/tisfeng/SelectedTextKit";
+ requirement = {
+ kind = revision;
+ revision = f6c1574ad6f747e1110b544c35e6ec64ed6f52f1;
+ };
+ };
E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ejbills/mediaremote-adapter";
@@ -698,6 +710,11 @@
package = E1ADD45D2CC544F100303ECB /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
+ E1BBBDCB2EB0DF6700C3ABFE /* SelectedTextKit */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = E1BBBDCA2EB0DF6700C3ABFE /* XCRemoteSwiftPackageReference "SelectedTextKit" */;
+ productName = SelectedTextKit;
+ };
E1D7EF982E35E16C00640029 /* MediaRemoteAdapter */ = {
isa = XCSwiftPackageProductDependency;
package = E1D7EF972E35E16C00640029 /* XCRemoteSwiftPackageReference "mediaremote-adapter" */;
diff --git a/VoiceInk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VoiceInk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 9cb1177..e1eab6f 100644
--- a/VoiceInk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/VoiceInk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,6 +1,15 @@
{
- "originHash" : "0b9379abd19d2f53581c233273d09235e935a8d2b1180cf253dd69baa2784b39",
+ "originHash" : "53977eb7bc5f27d05d8be33f99b4c824929a33b2f189fcc8c4c2a6662bcd8075",
"pins" : [
+ {
+ "identity" : "axswift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/clavierorg/AXSwift.git",
+ "state" : {
+ "revision" : "2efa201fa18184de98674165592fd1ecb0c00a6d",
+ "version" : "0.3.5"
+ }
+ },
{
"identity" : "fluidaudio",
"kind" : "remoteSourceControl",
@@ -19,6 +28,15 @@
"version" : "2.4.0"
}
},
+ {
+ "identity" : "keysender",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/jordanbaird/KeySender",
+ "state" : {
+ "revision" : "99584bf1a03ab600cc44be89a2dcd98b2dbeb9de",
+ "version" : "0.0.5"
+ }
+ },
{
"identity" : "launchatlogin-modern",
"kind" : "remoteSourceControl",
@@ -37,6 +55,14 @@
"revision" : "3529aa25023082a2ceadebcd2c9c4a9430ee96b9"
}
},
+ {
+ "identity" : "selectedtextkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tisfeng/SelectedTextKit",
+ "state" : {
+ "revision" : "f6c1574ad6f747e1110b544c35e6ec64ed6f52f1"
+ }
+ },
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
diff --git a/VoiceInk/Services/AIEnhancementService.swift b/VoiceInk/Services/AIEnhancementService.swift
index 6578e00..0ab70f6 100644
--- a/VoiceInk/Services/AIEnhancementService.swift
+++ b/VoiceInk/Services/AIEnhancementService.swift
@@ -139,8 +139,8 @@ class AIEnhancementService: ObservableObject {
lastRequestTime = Date()
}
- private func getSystemMessage(for mode: EnhancementPrompt) -> String {
- let selectedText = SelectedTextService.fetchSelectedText()
+ private func getSystemMessage(for mode: EnhancementPrompt) async -> String {
+ let selectedText = await SelectedTextService.fetchSelectedText()
let selectedTextContext = if let selectedText = selectedText, !selectedText.isEmpty {
"\n\n\n\(selectedText)\n"
@@ -198,7 +198,7 @@ class AIEnhancementService: ObservableObject {
}
let formattedText = "\n\n\(text)\n"
- let systemMessage = getSystemMessage(for: mode)
+ let systemMessage = await getSystemMessage(for: mode)
// Persist the exact payload being sent (also used for UI)
await MainActor.run {
diff --git a/VoiceInk/Services/SelectedTextService.swift b/VoiceInk/Services/SelectedTextService.swift
index 78c8706..754d5d4 100644
--- a/VoiceInk/Services/SelectedTextService.swift
+++ b/VoiceInk/Services/SelectedTextService.swift
@@ -1,186 +1,16 @@
import Foundation
import AppKit
-import ApplicationServices
+import SelectedTextKit
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) {
+ static func fetchSelectedText() async -> String? {
+ let strategies: [TextStrategy] = [.accessibility, .menuAction, .shortcut]
+ do {
+ let selectedText = try await SelectedTextManager.shared.getSelectedText(strategies: strategies)
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 {
+ } catch {
+ print("Failed to get selected text: \(error)")
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)
}
}