feat(selection): Improve get selected text solution on macos, close #4022

This commit is contained in:
qianlifeng 2025-03-03 21:14:43 +08:00
parent b09bd93aa7
commit fd0eef35bd
No known key found for this signature in database
18 changed files with 381 additions and 28 deletions

5
.vscode/launch.json vendored
View File

@ -25,7 +25,10 @@
"compounds": [
{
"name": "Run Wox",
"configurations": ["Run Go", "Run Flutter"],
"configurations": [
"Run Go",
"Run Flutter"
],
"stopAll": true
}
]

View File

@ -14,6 +14,7 @@ import (
"wox/setting"
"wox/ui"
"wox/util"
"wox/util/selection"
"golang.design/x/hotkey/mainthread"
@ -135,7 +136,7 @@ func main() {
shareUI := ui.GetUIManager().GetUI(ctx)
plugin.GetPluginManager().Start(ctx, shareUI)
util.InitSelection()
selection.InitSelection()
// hotkey must be registered in main thread
mainthread.Init(func() {

View File

@ -13,6 +13,7 @@ import (
"wox/share"
"wox/ui"
"wox/util"
"wox/util/selection"
)
func TestCalculatorCrypto(t *testing.T) {
@ -369,7 +370,7 @@ func initServices() {
time.Sleep(time.Second * 10)
// Initialize selection
util.InitSelection()
selection.InitSelection()
}
type queryTest struct {

View File

@ -13,6 +13,7 @@ import (
"wox/setting/definition"
"wox/share"
"wox/util"
"wox/util/selection"
"github.com/google/uuid"
"github.com/tidwall/gjson"
@ -247,7 +248,7 @@ func (w *WebsocketHost) handleRequestFromPlugin(ctx context.Context, request Jso
return
}
var selection util.Selection
var selection selection.Selection
unmarshalSelectionErr := json.Unmarshal([]byte(querySelection), &selection)
if unmarshalSelectionErr != nil {
util.GetLogger().Error(ctx, fmt.Sprintf("[%s] failed to unmarshal selection: %s", request.PluginName, unmarshalSelectionErr))

View File

@ -20,6 +20,7 @@ import (
"wox/share"
"wox/util"
"wox/util/notifier"
"wox/util/selection"
"github.com/Masterminds/semver/v3"
"github.com/google/uuid"
@ -498,13 +499,13 @@ func (m *Manager) PolishResult(ctx context.Context, pluginInstance *Instance, qu
// add default preview for selection query if no preview is set
if query.Type == QueryTypeSelection && result.Preview.PreviewType == "" {
if query.Selection.Type == util.SelectionTypeText {
if query.Selection.Type == selection.SelectionTypeText {
result.Preview = WoxPreview{
PreviewType: WoxPreviewTypeText,
PreviewData: query.Selection.Text,
}
}
if query.Selection.Type == util.SelectionTypeFile {
if query.Selection.Type == selection.SelectionTypeFile {
result.Preview = WoxPreview{
PreviewType: WoxPreviewTypeMarkdown,
PreviewData: m.formatFileListPreview(ctx, query.Selection.FilePaths),
@ -1160,14 +1161,14 @@ func (m *Manager) polishPreview(ctx context.Context, preview WoxPreview) WoxPrev
func (m *Manager) ReplaceQueryVariable(ctx context.Context, query string) string {
if strings.Contains(query, QueryVariableSelectedText) {
selection, selectedErr := util.GetSelected()
selected, selectedErr := selection.GetSelected(ctx)
if selectedErr != nil {
logger.Error(ctx, fmt.Sprintf("failed to get selected text: %s", selectedErr.Error()))
} else {
if selection.Type == util.SelectionTypeText {
query = strings.ReplaceAll(query, QueryVariableSelectedText, selection.Text)
if selected.Type == selection.SelectionTypeText {
query = strings.ReplaceAll(query, QueryVariableSelectedText, selected.Text)
} else {
logger.Error(ctx, fmt.Sprintf("selected data is not text, type: %s", selection.Type))
logger.Error(ctx, fmt.Sprintf("selected data is not text, type: %s", selected.Type))
}
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"strings"
"wox/util"
"wox/util/selection"
"github.com/samber/lo"
)
@ -56,7 +57,7 @@ type Query struct {
// User selected or drag-drop data, can be text or file or image etc
//
// NOTE: Only available when query type is QueryTypeSelection
Selection util.Selection
Selection selection.Selection
// additional query environment data
// expose more context env data to plugin, E.g. plugin A only show result when active window title is "Chrome"

View File

@ -13,6 +13,7 @@ import (
"wox/share"
"wox/util"
"wox/util/clipboard"
"wox/util/selection"
"github.com/disintegration/imaging"
"github.com/samber/lo"
@ -167,12 +168,12 @@ func (c *Plugin) querySelection(ctx context.Context, query plugin.Query) []plugi
var results []plugin.QueryResult
for _, command := range commands {
if query.Selection.Type == util.SelectionTypeFile {
if query.Selection.Type == selection.SelectionTypeFile {
if !command.Vision {
continue
}
}
if query.Selection.Type == util.SelectionTypeText {
if query.Selection.Type == selection.SelectionTypeText {
if command.Vision {
continue
}
@ -231,7 +232,7 @@ func (c *Plugin) querySelection(ctx context.Context, query plugin.Query) []plugi
}
var conversations []ai.Conversation
if query.Selection.Type == util.SelectionTypeFile {
if query.Selection.Type == selection.SelectionTypeFile {
var images []image.Image
for _, imagePath := range query.Selection.FilePaths {
img, imgErr := imaging.Open(imagePath)
@ -246,7 +247,7 @@ func (c *Plugin) querySelection(ctx context.Context, query plugin.Query) []plugi
Images: images,
})
}
if query.Selection.Type == util.SelectionTypeText {
if query.Selection.Type == selection.SelectionTypeText {
conversations = append(conversations, ai.Conversation{
Role: ai.ConversationRoleUser,
Text: fmt.Sprintf(command.Prompt, query.Selection.Text),

View File

@ -5,7 +5,7 @@ import (
"fmt"
"strings"
"wox/plugin"
"wox/util"
"wox/util/selection"
)
func init() {
@ -50,7 +50,7 @@ func (i *PluginInstallerPlugin) Init(ctx context.Context, initParams plugin.Init
func (i *PluginInstallerPlugin) Query(ctx context.Context, query plugin.Query) []plugin.QueryResult {
if query.Type == plugin.QueryTypeSelection &&
query.Selection.Type == util.SelectionTypeFile &&
query.Selection.Type == selection.SelectionTypeFile &&
len(query.Selection.FilePaths) == 1 &&
strings.HasSuffix(query.Selection.FilePaths[0], ".wox") {
return i.queryForSelectionFile(ctx, query.Selection.FilePaths[0])

View File

@ -7,6 +7,7 @@ import (
"wox/util"
"wox/util/airdrop"
"wox/util/clipboard"
"wox/util/selection"
)
var selectionIcon = plugin.PluginSelectionIcon
@ -56,10 +57,10 @@ func (i *SelectionPlugin) Query(ctx context.Context, query plugin.Query) []plugi
return []plugin.QueryResult{}
}
if query.Selection.Type == util.SelectionTypeText {
if query.Selection.Type == selection.SelectionTypeText {
return i.queryForSelectionText(ctx, query.Selection.Text)
}
if query.Selection.Type == util.SelectionTypeFile {
if query.Selection.Type == selection.SelectionTypeFile {
return i.queryForSelectionFile(ctx, query.Selection.FilePaths)
}

View File

@ -11,6 +11,7 @@ import (
"wox/setting/definition"
"wox/setting/validator"
"wox/util"
"wox/util/selection"
)
var webSearchesSettingKey = "webSearches"
@ -298,7 +299,7 @@ func (r *WebSearchPlugin) QueryFallback(ctx context.Context, query plugin.Query)
func (r *WebSearchPlugin) querySelection(ctx context.Context, query plugin.Query) (results []plugin.QueryResult) {
//only support text selection
if query.Selection.Type == util.SelectionTypeFile {
if query.Selection.Type == selection.SelectionTypeFile {
return []plugin.QueryResult{}
}

View File

@ -2,13 +2,13 @@ package share
import (
"context"
"wox/util"
"wox/util/selection"
)
type PlainQuery struct {
QueryType string
QueryText string
QuerySelection util.Selection
QuerySelection selection.Selection
}
var DefaultSettingWindowContext = SettingWindowContext{Path: "/"}

View File

@ -19,6 +19,7 @@ import (
"wox/util/autostart"
"wox/util/hotkey"
"wox/util/ime"
"wox/util/selection"
"wox/util/tray"
"github.com/Masterminds/semver/v3"
@ -213,7 +214,7 @@ func (m *Manager) RegisterSelectionHotkey(ctx context.Context, combineKey string
return m.selectionHotkey.Register(ctx, combineKey, func() {
newCtx := util.NewTraceContext()
start := util.GetSystemTimestamp()
selection, err := util.GetSelected()
selection, err := selection.GetSelected(newCtx)
logger.Debug(newCtx, fmt.Sprintf("took %d ms to get selection", util.GetSystemTimestamp()-start))
if err != nil {
logger.Error(newCtx, fmt.Sprintf("failed to get selected: %s", err.Error()))

View File

@ -11,6 +11,7 @@ import (
"wox/share"
"wox/util"
"wox/util/notifier"
"wox/util/selection"
"wox/util/window"
"github.com/google/uuid"
@ -305,7 +306,7 @@ func handleWebsocketQuery(ctx context.Context, request WebsocketMsg) {
responseUIError(ctx, request, querySelectionErr.Error())
return
}
var querySelection util.Selection
var querySelection selection.Selection
json.Unmarshal([]byte(querySelectionJson), &querySelection)
var changedQuery share.PlainQuery

View File

@ -1,9 +1,11 @@
package util
package selection
import (
"context"
"errors"
"strings"
"time"
"wox/util"
"wox/util/clipboard"
"wox/util/keyboard"
)
@ -28,7 +30,7 @@ type Selection struct {
func InitSelection() {
clipboard.Watch(func(data clipboard.Data) {
lastClipboardChangeTimestamp = GetSystemTimestamp()
lastClipboardChangeTimestamp = util.GetSystemTimestamp()
})
}
@ -54,8 +56,8 @@ func (s *Selection) IsEmpty() bool {
return false
}
func GetSelected() (Selection, error) {
simulateStartTimestamp := GetSystemTimestamp()
func getSelectedByClipboard(ctx context.Context) (Selection, error) {
simulateStartTimestamp := util.GetSystemTimestamp()
if keyboard.SimulateCopy() != nil {
return Selection{}, errors.New("error simulate ctrl c")
}

View File

@ -0,0 +1,100 @@
//go:build darwin
package selection
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices
#include <stdlib.h>
#include <stdbool.h>
char* getSelectedTextA11y();
char* getSelectedFilesA11y();
bool hasAccessibilityPermissions();
void muteAlertSound();
void restoreAlertSound();
*/
import "C"
import (
"context"
"errors"
"strings"
"unsafe"
"wox/util"
)
// GetSelected is the macOS implementation that tries A11y API first, then falls back to clipboard
func GetSelected(ctx context.Context) (Selection, error) {
// Try accessibility API first
// First try to get selected text
if text, err := getSelectedTextViaA11y(ctx); err == nil && text != "" {
util.GetLogger().Debug(ctx, "selection: Successfully got text via A11y")
return Selection{
Type: SelectionTypeText,
Text: text,
}, nil
}
// Then try to get selected files
if files, err := getSelectedFilesViaA11y(ctx); err == nil && len(files) > 0 {
util.GetLogger().Debug(ctx, "selection: Successfully got files via A11y")
return Selection{
Type: SelectionTypeFile,
FilePaths: files,
}, nil
}
// Fallback to clipboard method with muted alert sound
C.muteAlertSound()
defer C.restoreAlertSound()
util.GetLogger().Debug(ctx, "selection: Falling back to clipboard method")
return getSelectedByClipboard(ctx)
}
// hasA11yPermissions checks if the application has accessibility permissions
func hasA11yPermissions() bool {
return bool(C.hasAccessibilityPermissions())
}
// getSelectedTextViaA11y gets selected text using macOS Accessibility API
func getSelectedTextViaA11y(ctx context.Context) (string, error) {
if !hasA11yPermissions() {
util.GetLogger().Warn(ctx, "selection: No accessibility permissions")
return "", errors.New("no accessibility permissions")
}
cstr := C.getSelectedTextA11y()
if cstr == nil {
util.GetLogger().Debug(ctx, "selection: Failed to get selected text via A11y")
return "", errors.New("failed to get selected text via A11y")
}
defer C.free(unsafe.Pointer(cstr))
return C.GoString(cstr), nil
}
// getSelectedFilesViaA11y gets selected files using macOS Accessibility API
func getSelectedFilesViaA11y(ctx context.Context) ([]string, error) {
if !hasA11yPermissions() {
util.GetLogger().Warn(ctx, "selection: No accessibility permissions")
return nil, errors.New("no accessibility permissions")
}
cstr := C.getSelectedFilesA11y()
if cstr == nil {
util.GetLogger().Debug(ctx, "selection: Failed to get selected files via A11y")
return nil, errors.New("failed to get selected files via A11y")
}
defer C.free(unsafe.Pointer(cstr))
paths := C.GoString(cstr)
if paths == "" {
util.GetLogger().Debug(ctx, "selection: No files selected")
return nil, errors.New("no files selected")
}
// Split the paths by newline
return strings.Split(strings.TrimSpace(paths), "\n"), nil
}

View File

@ -0,0 +1,226 @@
#import <Cocoa/Cocoa.h>
#import <ApplicationServices/ApplicationServices.h>
// Get the selected text using Accessibility API
char* getSelectedTextA11y() {
@autoreleasepool {
// Get the current focused application
AXUIElementRef systemWideElement = AXUIElementCreateSystemWide();
AXUIElementRef focusedApp = NULL;
AXError error = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedApplicationAttribute, (CFTypeRef *)&focusedApp);
if (error != kAXErrorSuccess || focusedApp == NULL) {
if (systemWideElement) CFRelease(systemWideElement);
return NULL;
}
// Get the focused window
AXUIElementRef focusedWindow = NULL;
error = AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute, (CFTypeRef *)&focusedWindow);
if (error != kAXErrorSuccess || focusedWindow == NULL) {
if (focusedApp) CFRelease(focusedApp);
if (systemWideElement) CFRelease(systemWideElement);
return NULL;
}
// Get the focused element
AXUIElementRef focusedElement = NULL;
error = AXUIElementCopyAttributeValue(focusedWindow, kAXFocusedUIElementAttribute, (CFTypeRef *)&focusedElement);
if (error != kAXErrorSuccess || focusedElement == NULL) {
if (focusedWindow) CFRelease(focusedWindow);
if (focusedApp) CFRelease(focusedApp);
if (systemWideElement) CFRelease(systemWideElement);
return NULL;
}
// Try to get the selected text
CFTypeRef selectedTextRef = NULL;
error = AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextAttribute, &selectedTextRef);
if (error != kAXErrorSuccess || selectedTextRef == NULL) {
// If we can't get the selected text directly, try to get it from the selected text range
AXValueRef selectedTextRangeRef = NULL;
error = AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextRangeAttribute, (CFTypeRef *)&selectedTextRangeRef);
if (error == kAXErrorSuccess && selectedTextRangeRef != NULL) {
CFStringRef stringValue = NULL;
error = AXUIElementCopyAttributeValue(focusedElement, kAXValueAttribute, (CFTypeRef *)&stringValue);
if (error == kAXErrorSuccess && stringValue != NULL) {
CFRange range;
AXValueGetValue(selectedTextRangeRef, kAXValueCFRangeType, &range);
if (range.length > 0) {
CFStringRef selectedText = CFStringCreateWithSubstring(kCFAllocatorDefault, stringValue, range);
if (selectedText) {
const char* cStr = CFStringGetCStringPtr(selectedText, kCFStringEncodingUTF8);
if (cStr == NULL) {
// If direct access fails, copy to a buffer
CFIndex length = CFStringGetLength(selectedText);
CFIndex maxSize = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
char* buffer = (char*)malloc(maxSize);
if (buffer && CFStringGetCString(selectedText, buffer, maxSize, kCFStringEncodingUTF8)) {
CFRelease(selectedText);
// Clean up other resources
if (stringValue) CFRelease(stringValue);
if (selectedTextRangeRef) CFRelease(selectedTextRangeRef);
if (focusedElement) CFRelease(focusedElement);
if (focusedWindow) CFRelease(focusedWindow);
if (focusedApp) CFRelease(focusedApp);
if (systemWideElement) CFRelease(systemWideElement);
return buffer; // Caller must free this
}
if (buffer) free(buffer);
} else {
char* result = strdup(cStr);
CFRelease(selectedText);
// Clean up other resources
if (stringValue) CFRelease(stringValue);
if (selectedTextRangeRef) CFRelease(selectedTextRangeRef);
if (focusedElement) CFRelease(focusedElement);
if (focusedWindow) CFRelease(focusedWindow);
if (focusedApp) CFRelease(focusedApp);
if (systemWideElement) CFRelease(systemWideElement);
return result; // Caller must free this
}
CFRelease(selectedText);
}
}
if (stringValue) CFRelease(stringValue);
}
if (selectedTextRangeRef) CFRelease(selectedTextRangeRef);
}
} else {
// We got the selected text directly
const char* cStr = CFStringGetCStringPtr((CFStringRef)selectedTextRef, kCFStringEncodingUTF8);
if (cStr == NULL) {
// If direct access fails, copy to a buffer
CFIndex length = CFStringGetLength((CFStringRef)selectedTextRef);
CFIndex maxSize = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
char* buffer = (char*)malloc(maxSize);
if (buffer && CFStringGetCString((CFStringRef)selectedTextRef, buffer, maxSize, kCFStringEncodingUTF8)) {
CFRelease(selectedTextRef);
// Clean up other resources
if (focusedElement) CFRelease(focusedElement);
if (focusedWindow) CFRelease(focusedWindow);
if (focusedApp) CFRelease(focusedApp);
if (systemWideElement) CFRelease(systemWideElement);
return buffer; // Caller must free this
}
if (buffer) free(buffer);
} else {
char* result = strdup(cStr);
CFRelease(selectedTextRef);
// Clean up other resources
if (focusedElement) CFRelease(focusedElement);
if (focusedWindow) CFRelease(focusedWindow);
if (focusedApp) CFRelease(focusedApp);
if (systemWideElement) CFRelease(systemWideElement);
return result; // Caller must free this
}
CFRelease(selectedTextRef);
}
// Clean up resources
if (focusedElement) CFRelease(focusedElement);
if (focusedWindow) CFRelease(focusedWindow);
if (focusedApp) CFRelease(focusedApp);
if (systemWideElement) CFRelease(systemWideElement);
}
return NULL;
}
// Get selected files from Finder
char* getSelectedFilesA11y() {
@autoreleasepool {
// Get the Finder process
pid_t finderPID = 0;
NSArray *apps = [NSWorkspace.sharedWorkspace runningApplications];
for (NSRunningApplication *app in apps) {
if ([[app bundleIdentifier] isEqualToString:@"com.apple.finder"]) {
finderPID = [app processIdentifier];
break;
}
}
if (finderPID == 0) {
return NULL;
}
// Get the Finder application element
AXUIElementRef finderApp = AXUIElementCreateApplication(finderPID);
if (finderApp == NULL) {
return NULL;
}
// Get the focused window
AXUIElementRef focusedWindow = NULL;
AXError error = AXUIElementCopyAttributeValue(finderApp, kAXFocusedWindowAttribute, (CFTypeRef *)&focusedWindow);
if (error != kAXErrorSuccess || focusedWindow == NULL) {
if (finderApp) CFRelease(finderApp);
return NULL;
}
// Get the selected items
CFArrayRef selectedItems = NULL;
error = AXUIElementCopyAttributeValue(focusedWindow, kAXSelectedChildrenAttribute, (CFTypeRef *)&selectedItems);
if (error != kAXErrorSuccess || selectedItems == NULL) {
if (focusedWindow) CFRelease(focusedWindow);
if (finderApp) CFRelease(finderApp);
return NULL;
}
// Get the file paths
NSMutableString *paths = [NSMutableString string];
CFIndex count = CFArrayGetCount(selectedItems);
for (CFIndex i = 0; i < count; i++) {
AXUIElementRef item = (AXUIElementRef)CFArrayGetValueAtIndex(selectedItems, i);
CFStringRef filename = NULL;
error = AXUIElementCopyAttributeValue(item, kAXFilenameAttribute, (CFTypeRef *)&filename);
if (error == kAXErrorSuccess && filename != NULL) {
[paths appendFormat:@"%@\n", (__bridge NSString *)filename];
CFRelease(filename);
}
}
// Clean up resources
if (selectedItems) CFRelease(selectedItems);
if (focusedWindow) CFRelease(focusedWindow);
if (finderApp) CFRelease(finderApp);
if ([paths length] > 0) {
const char *cStr = [paths UTF8String];
return strdup(cStr); // Caller must free this
}
}
return NULL;
}
// Check if the application has accessibility permissions
bool hasAccessibilityPermissions() {
return AXIsProcessTrustedWithOptions(NULL);
}
// Temporarily mute system alert sound
void muteAlertSound() {
NSSound *sound = [NSSound soundNamed:@"Tink"];
if (sound) {
[sound setVolume:0.0];
}
}
// Restore system alert sound
void restoreAlertSound() {
NSSound *sound = [NSSound soundNamed:@"Tink"];
if (sound) {
[sound setVolume:1.0];
}
}

View File

@ -0,0 +1,12 @@
//go:build !darwin
package selection
import "context"
// GetSelected is the implementation for non-macOS platforms
// It directly uses the clipboard method
func GetSelected(ctx context.Context) (Selection, error) {
// Non-macOS platforms directly use clipboard method
return getSelectedByClipboard(ctx)
}

BIN
wox.core/wox Executable file

Binary file not shown.