Skip to content
Merged

i #17

Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 100 additions & 29 deletions dhavnii/Features/Clipboard/ClipboardManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
)
}
}
Loading