diff --git a/dhavnii/Features/Clipboard/ClipboardManager.swift b/dhavnii/Features/Clipboard/ClipboardManager.swift index 73801e8..f408d58 100644 --- a/dhavnii/Features/Clipboard/ClipboardManager.swift +++ b/dhavnii/Features/Clipboard/ClipboardManager.swift @@ -11,6 +11,26 @@ import Carbon /// Manages clipboard operations and auto-pasting class ClipboardManager { + private let autoPasteRetryDelay: TimeInterval = 0.12 + private let autoPasteMaxAttempts = 10 + private var autoPasteToken = UUID() + + private struct FocusedElementState { + let bundleIdentifier: String + let role: String? + let isEditable: Bool + + var isValidPasteTarget: Bool { + switch role { + case "AXTextField", "AXTextArea", "AXComboBox", "AXSearchField", "AXStaticText": + return true + case "AXWebArea", "AXGroup", "AXScrollArea": + return isEditable + default: + return false + } + } + } /// Copy text to the system clipboard func copyToClipboard(_ text: String) { @@ -19,32 +39,9 @@ class ClipboardManager { pasteboard.setString(text, forType: .string) } - /// Check if a text input field is currently focused - func isTextFieldFocused() -> Bool { - // Use Accessibility API to check if focused element is a text field - guard let focusedApp = NSWorkspace.shared.frontmostApplication else { - return false - } - - let appElement = AXUIElementCreateApplication(focusedApp.processIdentifier) - - var focusedElement: CFTypeRef? - let result = AXUIElementCopyAttributeValue(appElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) - - guard result == .success, let element = focusedElement else { - return false - } - - var role: CFTypeRef? - AXUIElementCopyAttributeValue(element as! AXUIElement, kAXRoleAttribute as CFString, &role) - - if let roleString = role as? String { - // Broaden supported roles to include web areas and editors (VS Code, Chrome, etc.) - let textRoles = ["AXTextField", "AXTextArea", "AXComboBox", "AXSearchField", "AXWebArea", "AXGroup", "AXStaticText"] - return textRoles.contains(roleString) - } - - return false + /// Check if the focused element can safely receive an auto-paste event + func isValidPasteTargetFocused() -> Bool { + focusedElementState()?.isValidPasteTarget ?? false } /// Simulate Cmd+V to paste @@ -67,6 +64,9 @@ class ClipboardManager { // Always copy to clipboard (mandatory) copyToClipboard(text) + let token = UUID() + autoPasteToken = token + let hasAccessibilityPermission = AXIsProcessTrusted() let frontmostBundleIdentifier = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "unknown" @@ -84,9 +84,80 @@ class ClipboardManager { NSApp.hide(nil) } - // Delay to allow focus to settle/switch back - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.simulatePaste() + // Wait for focus to return to a valid text target before posting Cmd+V. + attemptPasteWhenReady(remainingAttempts: autoPasteMaxAttempts, token: token) + } + + private func attemptPasteWhenReady(remainingAttempts: Int, token: UUID) { + guard token == autoPasteToken else { + print("⚠️ Auto-paste cancelled: stale request") + return + } + + guard remainingAttempts > 0 else { + let frontmostBundleIdentifier = + NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "unknown" + let role = focusedElementState()?.role ?? "unknown" + print( + "⚠️ Auto-paste skipped: no editable target became ready, frontmostApp=\(frontmostBundleIdentifier), role=\(role)" + ) + return + } + + if let focusedElement = focusedElementState(), + focusedElement.bundleIdentifier != Bundle.main.bundleIdentifier, + focusedElement.isValidPasteTarget + { + guard token == autoPasteToken else { + print("⚠️ Auto-paste cancelled before paste: stale request") + return + } + print( + "✅ Auto-paste target ready: frontmostApp=\(focusedElement.bundleIdentifier), role=\(focusedElement.role ?? "unknown"), editable=\(focusedElement.isEditable)" + ) + simulatePaste() + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + autoPasteRetryDelay) { [weak self] in + self?.attemptPasteWhenReady(remainingAttempts: remainingAttempts - 1, token: token) } } + + private func focusedElementState() -> FocusedElementState? { + guard let focusedApp = NSWorkspace.shared.frontmostApplication else { + return nil + } + + let appElement = AXUIElementCreateApplication(focusedApp.processIdentifier) + + var focusedElement: CFTypeRef? + let result = AXUIElementCopyAttributeValue( + appElement, + kAXFocusedUIElementAttribute as CFString, + &focusedElement + ) + + guard result == .success, let element = focusedElement else { + return FocusedElementState( + bundleIdentifier: focusedApp.bundleIdentifier ?? "unknown", + role: nil, + isEditable: false + ) + } + + let axElement = unsafeBitCast(element, to: AXUIElement.self) + + var roleValue: CFTypeRef? + AXUIElementCopyAttributeValue(axElement, kAXRoleAttribute as CFString, &roleValue) + + var editableValue: CFTypeRef? + AXUIElementCopyAttributeValue(axElement, "AXEditable" as CFString, &editableValue) + + return FocusedElementState( + bundleIdentifier: focusedApp.bundleIdentifier ?? "unknown", + role: roleValue as? String, + isEditable: editableValue as? Bool ?? false + ) + } }