//go:build darwin
|
|
package axinput
|
|
/*
|
#cgo CFLAGS: -x objective-c
|
#cgo LDFLAGS: -framework AppKit -framework ApplicationServices
|
#include <ApplicationServices/ApplicationServices.h>
|
#include <AppKit/AppKit.h>
|
#include <stdlib.h>
|
#include <string.h>
|
#include <unistd.h>
|
|
static char* pvAXCopyError(NSString* message) {
|
const char* utf8 = [message UTF8String];
|
if (!utf8) return strdup("unknown error");
|
return strdup(utf8);
|
}
|
|
static NSString* pvAXStringAttribute(AXUIElementRef element, CFStringRef attr) {
|
CFTypeRef valueRef = NULL;
|
AXError err = AXUIElementCopyAttributeValue(element, attr, &valueRef);
|
if (err != kAXErrorSuccess || !valueRef) return nil;
|
NSString* result = nil;
|
if (CFGetTypeID(valueRef) == CFStringGetTypeID()) {
|
result = [(__bridge NSString*)valueRef copy];
|
}
|
CFRelease(valueRef);
|
return [result autorelease];
|
}
|
|
static NSString* pvAXElementSummary(AXUIElementRef element) {
|
NSMutableArray<NSString*>* parts = [NSMutableArray array];
|
pid_t pid = 0;
|
if (AXUIElementGetPid(element, &pid) == kAXErrorSuccess && pid > 0) {
|
NSRunningApplication* app = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
|
NSString* name = [app localizedName];
|
if (name.length > 0) {
|
[parts addObject:[NSString stringWithFormat:@"app=%@", name]];
|
}
|
[parts addObject:[NSString stringWithFormat:@"pid=%d", pid]];
|
}
|
NSString* role = pvAXStringAttribute(element, kAXRoleAttribute);
|
if (role.length > 0) {
|
[parts addObject:[NSString stringWithFormat:@"role=%@", role]];
|
}
|
NSString* subrole = pvAXStringAttribute(element, kAXSubroleAttribute);
|
if (subrole.length > 0) {
|
[parts addObject:[NSString stringWithFormat:@"subrole=%@", subrole]];
|
}
|
if (parts.count == 0) return @"target=unknown";
|
return [parts componentsJoinedByString:@" "];
|
}
|
|
static BOOL pvAXAttributeSettable(AXUIElementRef element, CFStringRef attr, Boolean* settable) {
|
Boolean localSettable = false;
|
AXError err = AXUIElementIsAttributeSettable(element, attr, &localSettable);
|
if (settable) *settable = localSettable;
|
return err == kAXErrorSuccess;
|
}
|
|
static char* pvAXCopyResult(int ok, NSString* method, NSString* target, NSString* message) {
|
NSString* payload = [NSString stringWithFormat:@"%d\n%@\n%@\n%@",
|
ok,
|
method ?: @"ax_focused_element",
|
target ?: @"target=unknown",
|
message ?: @""];
|
return pvAXCopyError(payload);
|
}
|
|
static int pvAXTrusted(int prompt) {
|
if (prompt) {
|
NSDictionary* opts = @{(__bridge NSString*)kAXTrustedCheckOptionPrompt: @YES};
|
return AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)opts) ? 1 : 0;
|
}
|
return AXIsProcessTrusted() ? 1 : 0;
|
}
|
|
static AXUIElementRef pvAXCopyFrontmostFocusedElement(AXError* outErr) {
|
if (outErr) *outErr = kAXErrorFailure;
|
NSRunningApplication* app = [[NSWorkspace sharedWorkspace] frontmostApplication];
|
if (!app || app.processIdentifier <= 0) {
|
return NULL;
|
}
|
AXUIElementRef appElement = AXUIElementCreateApplication(app.processIdentifier);
|
if (!appElement) {
|
return NULL;
|
}
|
AXUIElementSetMessagingTimeout(appElement, 1.0);
|
AXUIElementRef focused = NULL;
|
AXError err = AXUIElementCopyAttributeValue(appElement, kAXFocusedUIElementAttribute, (CFTypeRef*)&focused);
|
CFRelease(appElement);
|
if (outErr) *outErr = err;
|
if (err != kAXErrorSuccess || !focused) {
|
return NULL;
|
}
|
return focused;
|
}
|
|
static AXUIElementRef pvAXCopyBestFocusedElement(AXError* outSystemErr, AXError* outFocusedAppErr, AXError* outFocusedAppElementErr, AXError* outFrontmostErr) {
|
if (outSystemErr) *outSystemErr = kAXErrorFailure;
|
if (outFocusedAppErr) *outFocusedAppErr = kAXErrorFailure;
|
if (outFocusedAppElementErr) *outFocusedAppElementErr = kAXErrorFailure;
|
if (outFrontmostErr) *outFrontmostErr = kAXErrorFailure;
|
|
AXUIElementRef system = AXUIElementCreateSystemWide();
|
if (!system) {
|
return NULL;
|
}
|
AXUIElementSetMessagingTimeout(system, 1.0);
|
|
AXUIElementRef focused = NULL;
|
AXError systemErr = AXUIElementCopyAttributeValue(system, kAXFocusedUIElementAttribute, (CFTypeRef*)&focused);
|
if (outSystemErr) *outSystemErr = systemErr;
|
if (systemErr == kAXErrorSuccess && focused) {
|
CFRelease(system);
|
return focused;
|
}
|
|
AXUIElementRef focusedApp = NULL;
|
AXError focusedAppErr = AXUIElementCopyAttributeValue(system, kAXFocusedApplicationAttribute, (CFTypeRef*)&focusedApp);
|
if (outFocusedAppErr) *outFocusedAppErr = focusedAppErr;
|
if (focusedAppErr == kAXErrorSuccess && focusedApp) {
|
AXUIElementSetMessagingTimeout(focusedApp, 1.0);
|
AXError focusedAppElementErr = AXUIElementCopyAttributeValue(focusedApp, kAXFocusedUIElementAttribute, (CFTypeRef*)&focused);
|
if (outFocusedAppElementErr) *outFocusedAppElementErr = focusedAppElementErr;
|
CFRelease(focusedApp);
|
if (focusedAppElementErr == kAXErrorSuccess && focused) {
|
CFRelease(system);
|
return focused;
|
}
|
}
|
CFRelease(system);
|
|
return pvAXCopyFrontmostFocusedElement(outFrontmostErr);
|
}
|
|
static char* pvAXDirectInsert(const char* text) {
|
if (!pvAXTrusted(0)) {
|
return pvAXCopyResult(0, @"ax_focused_element", nil, @"accessibility permission is not granted");
|
}
|
|
NSString* insert = [NSString stringWithUTF8String:text];
|
if (!insert) {
|
return pvAXCopyResult(0, @"ax_focused_element", nil, @"input text is not valid UTF-8");
|
}
|
|
AXUIElementRef focused = NULL;
|
AXError systemErr = kAXErrorFailure;
|
AXError focusedAppErr = kAXErrorFailure;
|
AXError focusedAppElementErr = kAXErrorFailure;
|
AXError frontmostErr = kAXErrorFailure;
|
for (int attempt = 0; attempt < 3 && !focused; attempt++) {
|
focused = pvAXCopyBestFocusedElement(&systemErr, &focusedAppErr, &focusedAppElementErr, &frontmostErr);
|
if (!focused && attempt < 2) {
|
usleep(50000);
|
}
|
}
|
if (!focused) {
|
return pvAXCopyResult(0, @"ax_focused_element", nil, [NSString stringWithFormat:@"focused element unavailable: system=%d focused_app=%d focused_app_element=%d frontmost=%d", systemErr, focusedAppErr, focusedAppElementErr, frontmostErr]);
|
}
|
|
NSString* target = pvAXElementSummary(focused);
|
|
CFTypeRef valueRef = NULL;
|
CFTypeRef rangeRef = NULL;
|
AXError valueErr = AXUIElementCopyAttributeValue(focused, kAXValueAttribute, &valueRef);
|
AXError rangeErr = AXUIElementCopyAttributeValue(focused, kAXSelectedTextRangeAttribute, &rangeRef);
|
if (valueErr != kAXErrorSuccess || rangeErr != kAXErrorSuccess || !valueRef || !rangeRef ||
|
CFGetTypeID(valueRef) != CFStringGetTypeID() || CFGetTypeID(rangeRef) != AXValueGetTypeID()) {
|
if (valueRef) CFRelease(valueRef);
|
if (rangeRef) CFRelease(rangeRef);
|
CFRelease(focused);
|
return pvAXCopyResult(0, @"ax_focused_element",
|
target,
|
[NSString stringWithFormat:@"focused text state is not readable; value=%d range=%d", valueErr, rangeErr]);
|
}
|
|
CFRange selectedRange;
|
if (!AXValueGetValue((AXValueRef)rangeRef, kAXValueCFRangeType, &selectedRange)) {
|
CFRelease(valueRef);
|
CFRelease(rangeRef);
|
CFRelease(focused);
|
return pvAXCopyResult(0, @"ax_value_replacement", target, @"focused element selected range is unreadable");
|
}
|
|
NSString* value = (__bridge NSString*)valueRef;
|
NSUInteger valueLength = [value length];
|
if (selectedRange.location < 0 || selectedRange.length < 0 ||
|
(NSUInteger)selectedRange.location > valueLength ||
|
(NSUInteger)(selectedRange.location + selectedRange.length) > valueLength) {
|
CFRelease(valueRef);
|
CFRelease(rangeRef);
|
CFRelease(focused);
|
return pvAXCopyResult(0, @"ax_value_replacement", target, @"focused element selected range is out of bounds");
|
}
|
|
Boolean valueSettable = false;
|
BOOL settableKnown = pvAXAttributeSettable(focused, kAXValueAttribute, &valueSettable);
|
if (settableKnown && !valueSettable) {
|
CFRelease(valueRef);
|
CFRelease(rangeRef);
|
CFRelease(focused);
|
return pvAXCopyResult(0,
|
@"ax_value_replacement",
|
target,
|
@"focused element value is not settable");
|
}
|
|
NSRange nsRange = NSMakeRange((NSUInteger)selectedRange.location, (NSUInteger)selectedRange.length);
|
NSString* updated = [value stringByReplacingCharactersInRange:nsRange withString:insert];
|
AXError setErr = AXUIElementSetAttributeValue(focused, kAXValueAttribute, (__bridge CFTypeRef)updated);
|
if (setErr == kAXErrorSuccess) {
|
CFRange newRange = CFRangeMake(selectedRange.location + [insert length], 0);
|
AXValueRef newRangeValue = AXValueCreate(kAXValueCFRangeType, &newRange);
|
if (newRangeValue) {
|
AXUIElementSetAttributeValue(focused, kAXSelectedTextRangeAttribute, newRangeValue);
|
CFRelease(newRangeValue);
|
}
|
}
|
|
CFTypeRef verifyRef = NULL;
|
AXError verifyErr = AXUIElementCopyAttributeValue(focused, kAXValueAttribute, &verifyRef);
|
BOOL verified = false;
|
if (verifyErr == kAXErrorSuccess && verifyRef && CFGetTypeID(verifyRef) == CFStringGetTypeID()) {
|
NSString* verifiedValue = (__bridge NSString*)verifyRef;
|
verified = [verifiedValue isEqualToString:updated];
|
}
|
if (verifyRef) CFRelease(verifyRef);
|
|
CFRelease(valueRef);
|
CFRelease(rangeRef);
|
CFRelease(focused);
|
if (setErr != kAXErrorSuccess) {
|
return pvAXCopyResult(0, @"ax_value_replacement", target, [NSString stringWithFormat:@"AX value replacement failed: %d", setErr]);
|
}
|
if (!verified) {
|
return pvAXCopyResult(0, @"ax_value_replacement", target, [NSString stringWithFormat:@"AX value replacement was not observable after write; verify=%d", verifyErr]);
|
}
|
return pvAXCopyResult(1, @"ax_value_replacement", target, @"focused element value changed after write");
|
}
|
|
static int pvSetClipboardText(const char* text) {
|
NSPasteboard* pb = [NSPasteboard generalPasteboard];
|
if (!pb) return 0;
|
NSString* str = [NSString stringWithUTF8String:text];
|
if (!str) return 0;
|
[pb clearContents];
|
NSArray<NSPasteboardType>* types = @[NSPasteboardTypeString];
|
[pb declareTypes:types owner:nil];
|
BOOL wrote = [pb setString:str forType:NSPasteboardTypeString];
|
NSString* verified = [pb stringForType:NSPasteboardTypeString];
|
return wrote && verified && [verified isEqualToString:str] ? 1 : 0;
|
}
|
|
static int pvClearClipboard(void) {
|
NSPasteboard* pb = [NSPasteboard generalPasteboard];
|
if (!pb) return 0;
|
[pb clearContents];
|
return [pb stringForType:NSPasteboardTypeString] == nil ? 1 : 0;
|
}
|
|
static char* pvGetClipboardText(void) {
|
NSPasteboard* pb = [NSPasteboard generalPasteboard];
|
NSString* str = [pb stringForType:NSPasteboardTypeString];
|
if (!str) return NULL;
|
const char* utf8 = [str UTF8String];
|
if (!utf8) return NULL;
|
return strdup(utf8);
|
}
|
|
static char* pvCopyFrontmostAppIdentity(void) {
|
NSRunningApplication* app = [[NSWorkspace sharedWorkspace] frontmostApplication];
|
if (!app || app.processIdentifier <= 0) return NULL;
|
NSString* bundleID = [app bundleIdentifier] ?: @"";
|
NSString* identity = [NSString stringWithFormat:@"bundle=%@ pid=%d", bundleID, app.processIdentifier];
|
const char* utf8 = [identity UTF8String];
|
if (!utf8) return NULL;
|
return strdup(utf8);
|
}
|
|
static int pvPostCommandVIfFrontmostUnchanged(const char* expectedIdentity) {
|
if (!expectedIdentity || strlen(expectedIdentity) == 0) return -1;
|
char* currentIdentity = pvCopyFrontmostAppIdentity();
|
if (!currentIdentity) return -1;
|
int same = strcmp(currentIdentity, expectedIdentity) == 0;
|
free(currentIdentity);
|
if (!same) return 0;
|
|
CGEventSourceRef source = CGEventSourceCreate(kCGEventSourceStateCombinedSessionState);
|
if (!source) return -2;
|
CGEventRef keyDown = CGEventCreateKeyboardEvent(source, (CGKeyCode)9, true);
|
CGEventRef keyUp = CGEventCreateKeyboardEvent(source, (CGKeyCode)9, false);
|
if (!keyDown || !keyUp) {
|
if (keyDown) CFRelease(keyDown);
|
if (keyUp) CFRelease(keyUp);
|
CFRelease(source);
|
return -2;
|
}
|
CGEventSetFlags(keyDown, kCGEventFlagMaskCommand);
|
CGEventSetFlags(keyUp, kCGEventFlagMaskCommand);
|
CGEventPost(kCGHIDEventTap, keyDown); // AX10DT_EXPERIMENT_ALLOW_CGEVENTPOST: one-shot Cmd+V only when explicitly enabled by config.
|
CGEventPost(kCGHIDEventTap, keyUp); // AX10DT_EXPERIMENT_ALLOW_CGEVENTPOST: one-shot Cmd+V only when explicitly enabled by config.
|
CFRelease(keyDown);
|
CFRelease(keyUp);
|
CFRelease(source);
|
return 1;
|
}
|
*/
|
import "C"
|
|
import (
|
"strings"
|
"unsafe"
|
)
|
|
type darwinDriver struct {
|
clipboard clipboardState
|
}
|
|
var (
|
readClipboardSnapshot = currentClipboardSnapshot
|
writeClipboardText = writeClipboardTextDarwin
|
clearClipboardText = clearClipboardTextDarwin
|
)
|
|
func newPlatformDriver() Driver {
|
return &darwinDriver{}
|
}
|
|
func (d *darwinDriver) AccessibilityTrusted(prompt bool) bool {
|
if prompt {
|
return C.pvAXTrusted(1) != 0
|
}
|
return C.pvAXTrusted(0) != 0
|
}
|
|
func (d *darwinDriver) DirectInsert(text string) DirectInsertResult {
|
if text == "" {
|
return DirectInsertResult{OK: false, Method: "ax_focused_element", Message: "text is empty"}
|
}
|
cText := C.CString(text)
|
defer C.free(unsafe.Pointer(cText))
|
cResult := C.pvAXDirectInsert(cText)
|
if cResult == nil {
|
return DirectInsertResult{
|
OK: false,
|
Inserted: false,
|
NeedsPermission: !d.AccessibilityTrusted(false),
|
Method: "ax_focused_element",
|
Message: "AX direct insert returned no result",
|
}
|
}
|
defer C.free(unsafe.Pointer(cResult))
|
ok, method, target, msg := parseAXDirectInsertPayload(C.GoString(cResult))
|
if !ok {
|
return DirectInsertResult{
|
OK: false,
|
Inserted: false,
|
NeedsPermission: !d.AccessibilityTrusted(false),
|
Method: method,
|
Message: msg,
|
Target: target,
|
}
|
}
|
return DirectInsertResult{
|
OK: true,
|
Inserted: true,
|
Method: method,
|
Message: msg,
|
Target: target,
|
}
|
}
|
|
func parseAXDirectInsertPayload(payload string) (bool, string, string, string) {
|
parts := strings.SplitN(payload, "\n", 4)
|
if len(parts) != 4 {
|
return false, "ax_focused_element", "", payload
|
}
|
return parts[0] == "1", parts[1], parts[2], parts[3]
|
}
|
|
func (d *darwinDriver) CopyTextToClipboard(text string) ClipboardResult {
|
if text == "" {
|
return ClipboardResult{OK: false, Message: "text is empty"}
|
}
|
if !writeClipboardText(text) {
|
return ClipboardResult{
|
OK: false,
|
Message: "clipboard could not be written",
|
}
|
}
|
return ClipboardResult{
|
OK: true,
|
Prepared: true,
|
Message: "clipboard updated; user must paste manually",
|
}
|
}
|
|
func (d *darwinDriver) FrontmostAppIdentity() FrontmostAppIdentity {
|
cIdentity := C.pvCopyFrontmostAppIdentity()
|
if cIdentity == nil {
|
return FrontmostAppIdentity{OK: false, Message: "frontmost app identity is unavailable"}
|
}
|
defer C.free(unsafe.Pointer(cIdentity))
|
identity := C.GoString(cIdentity)
|
if strings.TrimSpace(identity) == "" {
|
return FrontmostAppIdentity{OK: false, Message: "frontmost app identity is empty"}
|
}
|
return FrontmostAppIdentity{OK: true, Identity: identity}
|
}
|
|
func (d *darwinDriver) AutoPasteClipboardIfFrontmostUnchanged(expectedIdentity string) AutoPasteResult {
|
if strings.TrimSpace(expectedIdentity) == "" {
|
return AutoPasteResult{OK: false, Message: "frontmost app identity is unavailable"}
|
}
|
cExpected := C.CString(expectedIdentity)
|
defer C.free(unsafe.Pointer(cExpected))
|
switch C.pvPostCommandVIfFrontmostUnchanged(cExpected) {
|
case 1:
|
return AutoPasteResult{OK: true, Pasted: true, FrontmostUnchanged: true, Message: "automatic paste event sent"}
|
case 0:
|
return AutoPasteResult{OK: false, Pasted: false, FrontmostUnchanged: false, Message: "frontmost app changed before automatic paste"}
|
default:
|
return AutoPasteResult{OK: false, Pasted: false, FrontmostUnchanged: false, Message: "automatic paste event could not be sent"}
|
}
|
}
|
|
func (d *darwinDriver) PrepareClipboardFallback(text string) ClipboardResult {
|
if text == "" {
|
return ClipboardResult{OK: false, Message: "text is empty"}
|
}
|
restore := readClipboardSnapshot()
|
if !writeClipboardText(text) {
|
return ClipboardResult{
|
OK: false,
|
Prepared: false,
|
Message: "clipboard fallback could not be written",
|
}
|
}
|
token := d.clipboard.remember(text, restore)
|
return ClipboardResult{
|
OK: true,
|
Prepared: true,
|
Token: token,
|
Message: "clipboard fallback prepared; user must paste manually",
|
}
|
}
|
|
func (d *darwinDriver) RestoreClipboardFallback(token string) ClipboardResult {
|
entry, ok := d.clipboard.take(token)
|
if !ok {
|
return ClipboardResult{OK: false, Message: "unknown or already restored fallback token"}
|
}
|
current := readClipboardSnapshot()
|
if !shouldRestoreClipboard(current, entry.fallbackText) {
|
return ClipboardResult{
|
OK: false,
|
Restored: false,
|
Message: "clipboard changed after fallback preparation; restore skipped",
|
}
|
}
|
if entry.restore.hasText {
|
if !writeClipboardText(entry.restore.text) {
|
return ClipboardResult{OK: false, Restored: false, Message: "clipboard restore failed"}
|
}
|
return ClipboardResult{OK: true, Restored: true, Message: "clipboard restored"}
|
}
|
if !clearClipboardText() {
|
return ClipboardResult{OK: false, Restored: false, Message: "clipboard restore failed"}
|
}
|
return ClipboardResult{OK: true, Restored: true, Message: "clipboard restored"}
|
}
|
|
func writeClipboardTextDarwin(text string) bool {
|
cText := C.CString(text)
|
defer C.free(unsafe.Pointer(cText))
|
return C.pvSetClipboardText(cText) != 0
|
}
|
|
func clearClipboardTextDarwin() bool {
|
return C.pvClearClipboard() != 0
|
}
|
|
func currentClipboardSnapshot() clipboardSnapshot {
|
cText := C.pvGetClipboardText()
|
if cText == nil {
|
return clipboardSnapshot{}
|
}
|
defer C.free(unsafe.Pointer(cText))
|
return clipboardSnapshot{text: C.GoString(cText), hasText: true}
|
}
|