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) } }