From 789bde944c83eb9c511d8ca04867d08a067f423b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 27 May 2025 10:07:01 +0200 Subject: [PATCH] break apart large files --- Package.swift | 8 +- Sources/AXorcist/Core/AXCommands.swift | 256 +++++++ Sources/AXorcist/Core/AXObserverCenter.swift | 327 ++------- Sources/AXorcist/Core/AnyCodable.swift | 88 +++ Sources/AXorcist/Core/CommandEnvelope.swift | 115 +++ Sources/AXorcist/Core/CommandModels.swift | 672 +----------------- Sources/AXorcist/Core/CommandTypes.swift | 32 + Sources/AXorcist/Core/MatchingTypes.swift | 135 ++++ Sources/AXorcist/Core/NotificationTypes.swift | 42 ++ .../AXorcist/Core/NotificationWatcher.swift | 2 +- Sources/AXorcist/Core/ObserverHelpers.swift | 54 ++ Sources/AXorcist/Core/ObserverTypes.swift | 34 + Sources/AXorcist/Core/ValueFormatOption.swift | 11 + .../AXorcist/Search/AttributeBuilders.swift | 132 ++++ .../AXorcist/Search/AttributeExtractors.swift | 157 ++++ .../AXorcist/Search/AttributeFormatters.swift | 166 +++++ .../AXorcist/Search/AttributeHelpers.swift | 543 +------------- Sources/AXorcist/Search/AttributeTypes.swift | 34 + Sources/AXorcist/Search/GeometryHelpers.swift | 23 + .../AXorcist/Search/PathNavigationCore.swift | 86 +++ .../AXorcist/Search/PathNavigationJSON.swift | 190 +++++ .../Search/PathNavigationMatching.swift | 76 ++ .../Search/PathNavigationUtilities.swift | 132 ++++ Sources/AXorcist/Search/PathNavigator.swift | 518 +------------- Sources/axorc/CommandExecutionFunctions.swift | 67 ++ Sources/axorc/CommandExecutor.swift | 158 ++++ Sources/axorc/CommandHandlers.swift | 118 +++ Sources/axorc/CommandResponseHelpers.swift | 95 +++ Sources/axorc/CommandTypeExtensions.swift | 160 +++++ Sources/axorc/Core/CommandExecutor.swift | 574 --------------- 30 files changed, 2452 insertions(+), 2553 deletions(-) create mode 100644 Sources/AXorcist/Core/AXCommands.swift create mode 100644 Sources/AXorcist/Core/AnyCodable.swift create mode 100644 Sources/AXorcist/Core/CommandEnvelope.swift create mode 100644 Sources/AXorcist/Core/CommandTypes.swift create mode 100644 Sources/AXorcist/Core/MatchingTypes.swift create mode 100644 Sources/AXorcist/Core/NotificationTypes.swift create mode 100644 Sources/AXorcist/Core/ObserverHelpers.swift create mode 100644 Sources/AXorcist/Core/ObserverTypes.swift create mode 100644 Sources/AXorcist/Core/ValueFormatOption.swift create mode 100644 Sources/AXorcist/Search/AttributeBuilders.swift create mode 100644 Sources/AXorcist/Search/AttributeExtractors.swift create mode 100644 Sources/AXorcist/Search/AttributeFormatters.swift create mode 100644 Sources/AXorcist/Search/AttributeTypes.swift create mode 100644 Sources/AXorcist/Search/GeometryHelpers.swift create mode 100644 Sources/AXorcist/Search/PathNavigationCore.swift create mode 100644 Sources/AXorcist/Search/PathNavigationJSON.swift create mode 100644 Sources/AXorcist/Search/PathNavigationMatching.swift create mode 100644 Sources/AXorcist/Search/PathNavigationUtilities.swift create mode 100644 Sources/axorc/CommandExecutionFunctions.swift create mode 100644 Sources/axorc/CommandExecutor.swift create mode 100644 Sources/axorc/CommandHandlers.swift create mode 100644 Sources/axorc/CommandResponseHelpers.swift create mode 100644 Sources/axorc/CommandTypeExtensions.swift delete mode 100644 Sources/axorc/Core/CommandExecutor.swift diff --git a/Package.swift b/Package.swift index 4246693..7908d48 100644 --- a/Package.swift +++ b/Package.swift @@ -36,9 +36,13 @@ let package = Package( path: "Sources/axorc", // Explicit path sources: [ "AXORCMain.swift", - "Core/CommandExecutor.swift", "Core/InputHandler.swift", - "Models/AXORCModels.swift" + "Models/AXORCModels.swift", + "CommandExecutor.swift", + "CommandExecutionFunctions.swift", + "CommandHandlers.swift", + "CommandResponseHelpers.swift", + "CommandTypeExtensions.swift" ] ), .testTarget( diff --git a/Sources/AXorcist/Core/AXCommands.swift b/Sources/AXorcist/Core/AXCommands.swift new file mode 100644 index 0000000..b0e3671 --- /dev/null +++ b/Sources/AXorcist/Core/AXCommands.swift @@ -0,0 +1,256 @@ +// AXCommands.swift - AXCommand enum and individual command structs + +import CoreGraphics +import Foundation + +// MARK: - AXCommand and Command Structs + +// Enum representing all possible AX commands +public enum AXCommand: Sendable { + case query(QueryCommand) + case performAction(PerformActionCommand) + case getAttributes(GetAttributesCommand) + case describeElement(DescribeElementCommand) + case extractText(ExtractTextCommand) + case batch(AXBatchCommand) + case setFocusedValue(SetFocusedValueCommand) + case getElementAtPoint(GetElementAtPointCommand) + case getFocusedElement(GetFocusedElementCommand) + case observe(ObserveCommand) + case collectAll(CollectAllCommand) + + // Computed property to get command type + public var type: String { + switch self { + case .query: return "query" + case .performAction: return "performAction" + case .getAttributes: return "getAttributes" + case .describeElement: return "describeElement" + case .extractText: return "extractText" + case .batch: return "batch" + case .setFocusedValue: return "setFocusedValue" + case .getElementAtPoint: return "getElementAtPoint" + case .getFocusedElement: return "getFocusedElement" + case .observe: return "observe" + case .collectAll: return "collectAll" + } + } +} + +// Command envelope for AXorcist +public struct AXCommandEnvelope: Sendable { + public let commandID: String + public let command: AXCommand + + public init(commandID: String, command: AXCommand) { + self.commandID = commandID + self.command = command + } +} + +// Individual command structs +public struct QueryCommand: Sendable { + public let appIdentifier: String? + public let locator: Locator + public let attributesToReturn: [String]? + public let maxDepthForSearch: Int + public let includeChildrenBrief: Bool? + + public init(appIdentifier: String?, locator: Locator, attributesToReturn: [String]? = nil, maxDepthForSearch: Int = 10, includeChildrenBrief: Bool? = nil) { + self.appIdentifier = appIdentifier + self.locator = locator + self.attributesToReturn = attributesToReturn + self.maxDepthForSearch = maxDepthForSearch + self.includeChildrenBrief = includeChildrenBrief + } +} + +public struct PerformActionCommand: Sendable { + public let appIdentifier: String? + public let locator: Locator + public let action: String + public let value: AnyCodable? + public let maxDepthForSearch: Int + + public init(appIdentifier: String?, locator: Locator, action: String, value: AnyCodable? = nil, maxDepthForSearch: Int = 10) { + self.appIdentifier = appIdentifier + self.locator = locator + self.action = action + self.value = value + self.maxDepthForSearch = maxDepthForSearch + } +} + +public struct GetAttributesCommand: Sendable { + public let appIdentifier: String? + public let locator: Locator + public let attributes: [String] + public let maxDepthForSearch: Int + + public init(appIdentifier: String?, locator: Locator, attributes: [String], maxDepthForSearch: Int = 10) { + self.appIdentifier = appIdentifier + self.locator = locator + self.attributes = attributes + self.maxDepthForSearch = maxDepthForSearch + } +} + +public struct DescribeElementCommand: Sendable { + public let appIdentifier: String? + public let locator: Locator + public let formatOption: ValueFormatOption + public let maxDepthForSearch: Int + public let depth: Int + public let includeIgnored: Bool + public let maxSearchDepth: Int + + public init(appIdentifier: String?, locator: Locator, formatOption: ValueFormatOption = .smart, maxDepthForSearch: Int = 10, depth: Int = 3, includeIgnored: Bool = false, maxSearchDepth: Int = 10) { + self.appIdentifier = appIdentifier + self.locator = locator + self.formatOption = formatOption + self.maxDepthForSearch = maxDepthForSearch + self.depth = depth + self.includeIgnored = includeIgnored + self.maxSearchDepth = maxSearchDepth + } +} + +public struct ExtractTextCommand: Sendable { + public let appIdentifier: String? + public let locator: Locator + public let maxDepthForSearch: Int + public let includeChildren: Bool? + public let maxDepth: Int? + + public init(appIdentifier: String?, locator: Locator, maxDepthForSearch: Int = 10, includeChildren: Bool? = nil, maxDepth: Int? = nil) { + self.appIdentifier = appIdentifier + self.locator = locator + self.maxDepthForSearch = maxDepthForSearch + self.includeChildren = includeChildren + self.maxDepth = maxDepth + } +} + +public struct SetFocusedValueCommand: Sendable { + public let appIdentifier: String? + public let locator: Locator + public let value: String + public let maxDepthForSearch: Int + + public init(appIdentifier: String?, locator: Locator, value: String, maxDepthForSearch: Int = 10) { + self.appIdentifier = appIdentifier + self.locator = locator + self.value = value + self.maxDepthForSearch = maxDepthForSearch + } +} + +public struct GetElementAtPointCommand: Sendable { + public let point: CGPoint + public let appIdentifier: String? + public let pid: Int? + public let x: Float + public let y: Float + public let attributesToReturn: [String]? + public let includeChildrenBrief: Bool? + + public init(point: CGPoint, appIdentifier: String? = nil, pid: Int? = nil, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) { + self.point = point + self.appIdentifier = appIdentifier + self.pid = pid + self.x = Float(point.x) + self.y = Float(point.y) + self.attributesToReturn = attributesToReturn + self.includeChildrenBrief = includeChildrenBrief + } + + public init(appIdentifier: String?, x: Float, y: Float, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) { + self.point = CGPoint(x: CGFloat(x), y: CGFloat(y)) + self.appIdentifier = appIdentifier + self.pid = nil + self.x = x + self.y = y + self.attributesToReturn = attributesToReturn + self.includeChildrenBrief = includeChildrenBrief + } +} + +public struct GetFocusedElementCommand: Sendable { + public let appIdentifier: String? + public let attributesToReturn: [String]? + public let includeChildrenBrief: Bool? + + public init(appIdentifier: String?, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) { + self.appIdentifier = appIdentifier + self.attributesToReturn = attributesToReturn + self.includeChildrenBrief = includeChildrenBrief + } +} + +public struct ObserveCommand: Sendable { + public let appIdentifier: String? + public let locator: Locator? + public let notifications: [String] + public let includeDetails: Bool + public let watchChildren: Bool + public let notificationName: AXNotification + public let includeElementDetails: [String]? + public let maxDepthForSearch: Int + + public init(appIdentifier: String?, locator: Locator? = nil, notifications: [String], includeDetails: Bool = true, watchChildren: Bool = false, notificationName: AXNotification, includeElementDetails: [String]? = nil, maxDepthForSearch: Int = 10) { + self.appIdentifier = appIdentifier + self.locator = locator + self.notifications = notifications + self.includeDetails = includeDetails + self.watchChildren = watchChildren + self.notificationName = notificationName + self.includeElementDetails = includeElementDetails + self.maxDepthForSearch = maxDepthForSearch + } +} + +// Command struct for collectAll +public struct CollectAllCommand: Sendable { + public let appIdentifier: String? + public let attributesToReturn: [String]? + public let maxDepth: Int + public let filterCriteria: [String: String]? // JSON string for criteria, or can be decoded + public let valueFormatOption: ValueFormatOption? + + public init( + appIdentifier: String? = nil, // Provide default nil + attributesToReturn: [String]? = nil, + maxDepth: Int = 10, + filterCriteria: [String: String]? = nil, + valueFormatOption: ValueFormatOption? = .smart + ) { + self.appIdentifier = appIdentifier + self.attributesToReturn = attributesToReturn + self.maxDepth = maxDepth + self.filterCriteria = filterCriteria + self.valueFormatOption = valueFormatOption + } +} + +// Batch command structures +public struct AXBatchCommand: Sendable { + public struct SubCommandEnvelope: Sendable { + public let commandID: String + public let command: AXCommand + + public init(commandID: String, command: AXCommand) { + self.commandID = commandID + self.command = command + } + } + + public let commands: [SubCommandEnvelope] + + public init(commands: [SubCommandEnvelope]) { + self.commands = commands + } +} + +// Alias for backward compatibility if needed +public typealias AXSubCommand = AXCommand +public typealias BatchCommandEnvelope = AXBatchCommand \ No newline at end of file diff --git a/Sources/AXorcist/Core/AXObserverCenter.swift b/Sources/AXorcist/Core/AXObserverCenter.swift index 5de0d90..7346f6c 100644 --- a/Sources/AXorcist/Core/AXObserverCenter.swift +++ b/Sources/AXorcist/Core/AXObserverCenter.swift @@ -8,31 +8,6 @@ import ApplicationServices import Foundation -/// Callback type for observer notifications -// public typealias AXObserverHandler = @MainActor (pid_t, AXNotification, AXObserver, AXUIElement, CFDictionary?) -> Void // Old handler - -/// New callback type for subscriptions. The AXObserver and AXUIElement might be less relevant to the direct subscriber -/// if the Center abstracts them, or they can be added back if deemed necessary. -public typealias AXNotificationSubscriptionHandler = @MainActor (/*element: Element,*/ pid_t, AXNotification, _ rawElement: AXUIElement, _ nsUserInfo: [String: Any]?) -> Void - -/// Key for tracking registered notifications. Can allow nil PID for global observers for a specific notification type. -public struct AXNotificationSubscriptionKey: Hashable { - let pid: pid_t? // Optional to allow for global observers for a specific notification - let notification: AXNotification -} - -/// Key and PID pair for tracking registered notifications -public struct AXObserverKeyAndPID: Hashable { - let pid: pid_t - let key: AXNotification -} - -/// Observer and PID pair for tracking active observers -public struct AXObserverObjAndPID { - var observer: AXObserver - var pid: pid_t -} - /// Centralized manager for AXObserver instances @MainActor public class AXObserverCenter { @@ -48,11 +23,6 @@ public class AXObserverCenter { private var subscriptionTokens: [UUID: AXNotificationSubscriptionKey] = [:] private let subscriptionsLock = NSLock() // Added subscriptionsLock - /// Public token for unsubscribing - public struct SubscriptionToken: Hashable { - let id: UUID - } - /// Handler to be called when notifications are received - To be replaced by subscriptions model // public var handler: AXObserverHandler? @@ -148,6 +118,62 @@ public class AXObserverCenter { } } + // MARK: - Public Methods + + /// Remove all observers and all subscriptions. + @MainActor + public func removeAllObservers() { + axInfoLog("Removing all observers and subscriptions globally.") + subscriptionsLock.lock() + defer { subscriptionsLock.unlock() } + + // Unsubscribe all known tokens + for tokenID in subscriptionTokens.keys { + if let key = subscriptionTokens[tokenID] { // Safely unwrap + if var handlersForKey = subscriptions[key] { + handlersForKey.removeValue(forKey: tokenID) + if handlersForKey.isEmpty { + subscriptions.removeValue(forKey: key) + // Potential cleanup of underlying observer if no subscriptions remain for this specific key + cleanupUnderlyingObserverNotification(forPid: key.pid, notification: key.notification) + } else { + subscriptions[key] = handlersForKey + } + } + } + } + subscriptionTokens.removeAll() + + // After all unsubscriptions, observers and subscriptions should be empty. + if !self.observers.isEmpty || !self.subscriptions.isEmpty || !self.subscriptionTokens.isEmpty { // Added self. + axWarningLog("removeAllObservers: observers, subscriptions, or tokens list not empty after mass unsubscribe. observers: \(self.observers.count), subscriptions: \(self.subscriptions.count), tokens: \(self.subscriptionTokens.count)") // Added self. + // Force clear for safety, though unsubscribe should handle it. + self.observers.removeAll() // Added self. + self.subscriptions.removeAll() // Added self. + self.subscriptionTokens.removeAll() // Added self. + } + axInfoLog("All observers and subscriptions have been cleared.") + } + + /// Remove all observers for a specific process + public func removeAllObservers(for pid: pid_t) { + axInfoLog("Removing all observers and subscriptions for PID \(pid)") + let tokensForPid = subscriptionTokens.filter { $0.value.pid == pid }.map { $0.key } + for tokenId in tokensForPid { + try? unsubscribe(token: SubscriptionToken(id: tokenId)) + } + // Also handle global observers that might have been tied to this app if pid was 0 initially + // but that logic is complex and might be better handled by specific unsubscription. + // The current loop handles subscriptions explicitly tied to this PID. + } + + /// Check if a notification key is registered for a process + public func isKeyRegistered(pid: pid_t?, notification: AXNotification) -> Bool { // pid is now optional + // return observerKeys.contains { $0.pid == pid && $0.key == notification } // Old way + let key = AXNotificationSubscriptionKey(pid: pid, notification: notification) + return subscriptions[key]?.isEmpty == false + } + // MARK: - Internal AXObserver Management (previously addObserver / removeObserver) /// Ensures an AXObserver is created for the PID and the notification is added to it. @@ -236,60 +262,6 @@ public class AXObserverCenter { } } - /// Remove all observers and all subscriptions. - @MainActor - public func removeAllObservers() { - axInfoLog("Removing all observers and subscriptions globally.") - subscriptionsLock.lock() - defer { subscriptionsLock.unlock() } - - // Unsubscribe all known tokens - for tokenID in subscriptionTokens.keys { - if let key = subscriptionTokens[tokenID] { // Safely unwrap - if var handlersForKey = subscriptions[key] { - handlersForKey.removeValue(forKey: tokenID) - if handlersForKey.isEmpty { - subscriptions.removeValue(forKey: key) - // Potential cleanup of underlying observer if no subscriptions remain for this specific key - cleanupUnderlyingObserverNotification(forPid: key.pid, notification: key.notification) - } else { - subscriptions[key] = handlersForKey - } - } - } - } - subscriptionTokens.removeAll() - - // After all unsubscriptions, observers and subscriptions should be empty. - if !self.observers.isEmpty || !self.subscriptions.isEmpty || !self.subscriptionTokens.isEmpty { // Added self. - axWarningLog("removeAllObservers: observers, subscriptions, or tokens list not empty after mass unsubscribe. observers: \(self.observers.count), subscriptions: \(self.subscriptions.count), tokens: \(self.subscriptionTokens.count)") // Added self. - // Force clear for safety, though unsubscribe should handle it. - self.observers.removeAll() // Added self. - self.subscriptions.removeAll() // Added self. - self.subscriptionTokens.removeAll() // Added self. - } - axInfoLog("All observers and subscriptions have been cleared.") - } - - /// Remove all observers for a specific process - public func removeAllObservers(for pid: pid_t) { - axInfoLog("Removing all observers and subscriptions for PID \(pid)") - let tokensForPid = subscriptionTokens.filter { $0.value.pid == pid }.map { $0.key } - for tokenId in tokensForPid { - try? unsubscribe(token: SubscriptionToken(id: tokenId)) - } - // Also handle global observers that might have been tied to this app if pid was 0 initially - // but that logic is complex and might be better handled by specific unsubscription. - // The current loop handles subscriptions explicitly tied to this PID. - } - - /// Check if a notification key is registered for a process - public func isKeyRegistered(pid: pid_t?, notification: AXNotification) -> Bool { // pid is now optional - // return observerKeys.contains { $0.pid == pid && $0.key == notification } // Old way - let key = AXNotificationSubscriptionKey(pid: pid, notification: notification) - return subscriptions[key]?.isEmpty == false - } - // MARK: - Private Methods private func getObserver(for pid: pid_t) -> AXObserver? { @@ -325,7 +297,7 @@ public class AXObserverCenter { if let cfDict = cfUserInfo as? [CFString: CFTypeRef] { var tempDict = [String: Any]() for (key, value) in cfDict { - tempDict[key as String] = center.convertCFValueToSwift(value) + tempDict[key as String] = convertCFValueToSwift(value) } nsUserInfo = tempDict } else { @@ -374,194 +346,11 @@ public class AXObserverCenter { } } - // private func removeKey(pid: pid_t, key: AXNotification) { // Old method - // observerKeys.removeAll { $0.pid == pid && $0.key == key } - // } - private func removePidObserverInstance(pid: pid_t) { observers.removeAll { $0.pid == pid } axDebugLog("Removed AXObserver instance for effective PID \(pid).") } - // MARK: - Helper for userInfo conversion - private func convertCFValueToSwift(_ cfValue: CFTypeRef?) -> Any? { - guard let cfValue = cfValue else { return nil } - let typeID = CFGetTypeID(cfValue) - - switch typeID { - case CFStringGetTypeID(): - return cfValue as? String - case CFNumberGetTypeID(): - return cfValue as? NSNumber // Could be Int, Double, Bool (via NSNumber bridging) - case CFBooleanGetTypeID(): - // Ensure correct conversion for CFBoolean - if CFEqual(cfValue, kCFBooleanTrue) { - return true - } else if CFEqual(cfValue, kCFBooleanFalse) { - return false - } - // Fallback for other CFBoolean representations if any, or if direct Bool bridging works - if let boolVal = cfValue as? Bool { - return boolVal - } - axWarningLog("Could not convert CFBoolean to Bool: \(String(describing: cfValue))") - return nil // Or handle as error - case CFArrayGetTypeID(): - // Swift arrays bridge to CFArray, and CFArray can be cast to NSArray / [AnyObject] - if let cfArray = cfValue as? [CFTypeRef] { // or cfValue as? NSArray - return cfArray.compactMap { convertCFValueToSwift($0) } - } - axWarningLog("Failed to convert CFArray from userInfo.") - return cfValue // Return raw CFArray if conversion fails for some reason - case CFDictionaryGetTypeID(): - if let cfDict = cfValue as? [CFString: CFTypeRef] { // or cfValue as? NSDictionary - var swiftDict = [String: Any]() - for (key, value) in cfDict { - swiftDict[key as String] = convertCFValueToSwift(value) - } - return swiftDict - } - axWarningLog("Failed to convert nested CFDictionary from userInfo.") - return cfValue // Return raw CFDictionary if conversion fails - case AXUIElementGetTypeID(): - return cfValue as! AXUIElement // Should be safe to force unwrap if type matches - // Add other common CF types if necessary, e.g., CFURL, CFDate - default: - axDebugLog("Unhandled CFTypeRef in convertCFValueToSwift: typeID \(typeID). Value: \(cfValue)") - return cfValue // Return raw CFTypeRef if unhandled, caller might know what to do - } - } - - // Actual callback function that receives notifications - private func axObserverCallbackWithInfo( - _ observer: AXObserver!, - _ axElement: AXUIElement!, // Renamed to axElement - _ notification: CFString!, - _ userInfo: CFDictionary?, // This is CFDictionary?, which is correct - _ refcon: UnsafeMutableRawPointer! - ) { - let center = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - guard let axNotification = AXNotification(rawValue: notification as String) else { - axWarningLog("Received unknown notification: \(notification as String)") - return - } - - var pid: pid_t = 0 - let pidError = AXUIElementGetPid(axElement, &pid) - if pidError != .success { - if let observerInstance = center.observers.first(where: { $0.observer == observer }), observerInstance.pid != 0 { - pid = observerInstance.pid - axDebugLog("AXUIElementGetPid failed for observed element. Using PID from observer instance: \(pid). Notification: \(axNotification.rawValue)") - } else { - axWarningLog("AXUIElementGetPid failed for observed element and could not determine PID. Notification: \(axNotification.rawValue). Error: \(pidError.rawValue)") - } - } - - // Convert CFDictionary to [String: Any]? - var nsUserInfo: [String: Any]? - if let cfUserInfo = userInfo { - nsUserInfo = center.convertCFValueToSwift(cfUserInfo) as? [String: Any] - } - - // Dispatch to relevant handlers - // Check for PID-specific subscriptions - let specificKey = AXNotificationSubscriptionKey(pid: pid, notification: axNotification) - if let handlers = center.subscriptions[specificKey] { - axDebugLog("Dispatching to \(handlers.count) PID-specific handlers for PID \(pid), notification \(axNotification.rawValue)") - for handler in handlers.values { - handler(pid, axNotification, axElement, nsUserInfo) // Pass raw axElement, handler expects AXUIElement - } - } - - // Check for global subscriptions (pid == nil in key) - let globalKey = AXNotificationSubscriptionKey(pid: nil, notification: axNotification) - if let globalHandlers = center.subscriptions[globalKey] { - axDebugLog("Dispatching to \(globalHandlers.count) global handlers for notification \(axNotification.rawValue)") - for handler in globalHandlers.values { - handler(pid, axNotification, axElement, nsUserInfo) // Pass raw axElement, handler expects AXUIElement - } - } - - if center.subscriptions[specificKey] == nil && center.subscriptions[globalKey] == nil { - axWarningLog("No handlers found for notification \(axNotification.rawValue).") - } - } - - // Global notification callback function - private func axObserverCallback( - _ observer: AXObserver, - _ element: AXUIElement, - _ notificationName: CFString, - _ context: UnsafeMutableRawPointer? - ) { - // This is the older, simpler callback without userInfo. - // We will primarily use axObserverCallbackWithInfo if possible. - // However, some observers might still use this one if not configured for info. - - guard let context = context else { - axWarningLog("AXObserver callback invoked with nil context.") - return - } - let observerCenter = Unmanaged.fromOpaque(context).takeUnretainedValue() - let notification = AXNotification(rawValue: notificationName as String) ?? .created // Fallback if unknown - // Get PID from stored observer data instead of AXObserverGetPID (which doesn't exist in Swift) - var appPid: pid_t = 0 - if let observerData = observerCenter.observers.first(where: { $0.observer == observer }) { - appPid = observerData.pid - } else { - // Try to get PID from the element if we couldn't find the observer - AXUIElementGetPid(element, &appPid) - } - - Task { - observerCenter.processNotification(pid: appPid, notification: notification, rawElement: element, nsUserInfo: nil) - } - } - - // Global notification callback function WITH USERINFO - // This is the one we expect to be called by AXObserverAddNotification when userInfo is passed. - private func axObserverCallbackWithInfo( - _ observer: AXObserver, // The AXObserver instance that triggered the callback. - _ element: AXUIElement, // The AXUIElement that the notification is about. - _ notificationName: CFString, // The name of the notification (e.g., kAXFocusedUIElementChangedNotification). - _ userInfo: CFDictionary?, // An optional dictionary containing additional information about the notification. - _ context: UnsafeMutableRawPointer? // User-defined data passed when the observer was registered (self for AXObserverCenter). - ) { - guard let context = context else { - axWarningLog("AXObserver callback (with info) invoked with nil context.") - return - } - let observerCenter = Unmanaged.fromOpaque(context).takeUnretainedValue() - let notification = AXNotification(rawValue: notificationName as String) - - guard let axNotification = notification else { - axWarningLog("Received unknown notification: \(notificationName as String)") - return - } - - // Get the PID associated with the observer from our stored data - // (AXObserverGetPID doesn't exist in Swift) - var eventPid: pid_t = 0 - if let observerData = observerCenter.observers.first(where: { $0.observer == observer }) { - eventPid = observerData.pid - } - - // Try to get the PID of the element that the notification is about, which is more relevant. - var elementPid: pid_t = 0 - if AXUIElementGetPid(element, &elementPid) == .success { - eventPid = elementPid // Prefer the element's PID if available - } - - let swiftUserInfo = observerCenter.convertCFValueToSwift(userInfo) as? [String: Any] - - // Debug logging of the raw callback information - // axDebugLog("AXObserverCallbackWithInfo: PID=\(eventPid), Notification=\(axNotification.rawValue), Element=\(Element(element).briefDescription()), UserInfo=\(String(describing: swiftUserInfo))") - - Task { - observerCenter.processNotification(pid: eventPid, notification: axNotification, rawElement: element, nsUserInfo: swiftUserInfo) - } - } - // MARK: - Main Notification Processing (Called by global callbacks) @MainActor // Ensure this runs on the main actor as handlers are @MainActor fileprivate func processNotification(pid: pid_t, notification: AXNotification, rawElement: AXUIElement, nsUserInfo: [String: Any]?) { @@ -604,4 +393,4 @@ public class AXObserverCenter { handler(pid, notification, rawElement, nsUserInfo) } } -} +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/AnyCodable.swift b/Sources/AXorcist/Core/AnyCodable.swift new file mode 100644 index 0000000..e26ec8e --- /dev/null +++ b/Sources/AXorcist/Core/AnyCodable.swift @@ -0,0 +1,88 @@ +// AnyCodable.swift - Type-erased Codable wrapper for mixed-type payloads + +import Foundation + +// MARK: - AnyCodable for mixed-type payloads or attributes + +// Reverted to simpler AnyCodable with public 'value' to match widespread usage +public struct AnyCodable: Codable, @unchecked Sendable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self.value = () + } else if let bool = try? container.decode(Bool.self) { + self.value = bool + } else if let int = try? container.decode(Int.self) { + self.value = int + } else if let double = try? container.decode(Double.self) { + self.value = double + } else if let string = try? container.decode(String.self) { + self.value = string + } else if let array = try? container.decode([AnyCodable].self) { + self.value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + self.value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + if value is () { // Our nil marker for explicit nil + try container.encodeNil() + return + } + switch value { + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dictionary as [String: Any]: + try container.encode(dictionary.mapValues { AnyCodable($0) }) + default: + if let codableValue = value as? Encodable { + // If the value conforms to Encodable, let it encode itself using the provided encoder. + // This is the most flexible approach as the Encodable type can use any container type it needs. + try codableValue.encode(to: encoder) + } else if CFGetTypeID(value as CFTypeRef) == CFNullGetTypeID() { + try container.encodeNil() + } else { + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: [], debugDescription: "AnyCodable value (\(type(of: value))) cannot be encoded and does not conform to Encodable.")) + } + } + } +} + +// Helper struct for AnyCodable to properly encode intermediate Encodable values +// This might not be necessary if the direct (value as! Encodable).encode(to: encoder) works. +struct AnyCodablePośrednik: Encodable { + let value: T + init(_ value: T) { self.value = value } + func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } +} + +// Helper protocol to check if a type is Optional +fileprivate protocol OptionalProtocol { + static func isOptional() -> Bool +} + +extension Optional: OptionalProtocol { + static func isOptional() -> Bool { + return true + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/CommandEnvelope.swift b/Sources/AXorcist/Core/CommandEnvelope.swift new file mode 100644 index 0000000..4510d7c --- /dev/null +++ b/Sources/AXorcist/Core/CommandEnvelope.swift @@ -0,0 +1,115 @@ +// CommandEnvelope.swift - Main command envelope structure + +import CoreGraphics // For CGPoint +import Foundation + +// Main command envelope - REPLACED with definition from axorc.swift for consistency +public struct CommandEnvelope: Codable { + public let commandId: String + public let command: CommandType // Uses CommandType from this file + public let application: String? + public let attributes: [String]? + public let payload: [String: String]? // For ping compatibility + public let debugLogging: Bool + public let locator: Locator? // Locator from this file + public let pathHint: [String]? // This is likely legacy, Locator.rootElementPathHint is preferred + public let maxElements: Int? + public let maxDepth: Int? + public let outputFormat: OutputFormat? // OutputFormat from this file + public let actionName: String? // For performAction + public let actionValue: AnyCodable? // For performAction (AnyCodable from this file) + public let subCommands: [CommandEnvelope]? // For batch command + public let point: CGPoint? // Added for getElementAtPoint + public let pid: Int? // Added for getElementAtPoint (optional specific PID) + + // Parameters for 'observe' command + public let notifications: [String]? + public let includeElementDetails: [String]? + public let watchChildren: Bool? + + // New field for collectAll filtering + public let filterCriteria: [String: String]? + + // Additional fields for various commands + public let includeChildrenBrief: Bool? + public let includeChildrenInText: Bool? + public let includeIgnoredElements: Bool? + + enum CodingKeys: String, CodingKey { + case commandId + case command + case application + case attributes + case payload + case debugLogging + case locator + case pathHint + case maxElements + case maxDepth + case outputFormat + case actionName + case actionValue + case subCommands + case point + case pid + // CodingKeys for observe parameters + case notifications + case includeElementDetails + case watchChildren + // CodingKey for new field + case filterCriteria + // Additional CodingKeys + case includeChildrenBrief + case includeChildrenInText + case includeIgnoredElements + } + + public init(commandId: String, + command: CommandType, + application: String? = nil, + attributes: [String]? = nil, + payload: [String: String]? = nil, + debugLogging: Bool = false, + locator: Locator? = nil, + pathHint: [String]? = nil, + maxElements: Int? = nil, + maxDepth: Int? = nil, + outputFormat: OutputFormat? = nil, + actionName: String? = nil, + actionValue: AnyCodable? = nil, + subCommands: [CommandEnvelope]? = nil, + point: CGPoint? = nil, + pid: Int? = nil, + notifications: [String]? = nil, + includeElementDetails: [String]? = nil, + watchChildren: Bool? = nil, + filterCriteria: [String: String]? = nil, + includeChildrenBrief: Bool? = nil, + includeChildrenInText: Bool? = nil, + includeIgnoredElements: Bool? = nil + ) { + self.commandId = commandId + self.command = command + self.application = application + self.attributes = attributes + self.payload = payload + self.debugLogging = debugLogging + self.locator = locator + self.pathHint = pathHint + self.maxElements = maxElements + self.maxDepth = maxDepth + self.outputFormat = outputFormat + self.actionName = actionName + self.actionValue = actionValue + self.subCommands = subCommands + self.point = point + self.pid = pid + self.notifications = notifications + self.includeElementDetails = includeElementDetails + self.watchChildren = watchChildren + self.filterCriteria = filterCriteria + self.includeChildrenBrief = includeChildrenBrief + self.includeChildrenInText = includeChildrenInText + self.includeIgnoredElements = includeIgnoredElements + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/CommandModels.swift b/Sources/AXorcist/Core/CommandModels.swift index ae704de..7d84971 100644 --- a/Sources/AXorcist/Core/CommandModels.swift +++ b/Sources/AXorcist/Core/CommandModels.swift @@ -1,660 +1,12 @@ -// CommandModels.swift - Contains command-related model structs - -import CoreGraphics // For CGPoint -import Foundation - -// Enum for specifying how values, especially for descriptions, should be formatted. -public enum ValueFormatOption: String, Codable, Sendable { - case smart // Tries to provide the most useful, possibly summarized, representation. - case raw // Provides the raw or complete value, potentially verbose. - case textContent // Specifically for text content extraction, might ignore non-textual parts. - case stringified // For detailed string representation, often for logging or debugging. -} - -// Main command envelope - REPLACED with definition from axorc.swift for consistency -public struct CommandEnvelope: Codable { - public let commandId: String - public let command: CommandType // Uses CommandType from this file - public let application: String? - public let attributes: [String]? - public let payload: [String: String]? // For ping compatibility - public let debugLogging: Bool - public let locator: Locator? // Locator from this file - public let pathHint: [String]? // This is likely legacy, Locator.rootElementPathHint is preferred - public let maxElements: Int? - public let maxDepth: Int? - public let outputFormat: OutputFormat? // OutputFormat from this file - public let actionName: String? // For performAction - public let actionValue: AnyCodable? // For performAction (AnyCodable from this file) - public let subCommands: [CommandEnvelope]? // For batch command - public let point: CGPoint? // Added for getElementAtPoint - public let pid: Int? // Added for getElementAtPoint (optional specific PID) - - // Parameters for 'observe' command - public let notifications: [String]? - public let includeElementDetails: [String]? - public let watchChildren: Bool? - - // New field for collectAll filtering - public let filterCriteria: [String: String]? - - // Additional fields for various commands - public let includeChildrenBrief: Bool? - public let includeChildrenInText: Bool? - public let includeIgnoredElements: Bool? - - enum CodingKeys: String, CodingKey { - case commandId - case command - case application - case attributes - case payload - case debugLogging - case locator - case pathHint - case maxElements - case maxDepth - case outputFormat - case actionName - case actionValue - case subCommands - case point - case pid - // CodingKeys for observe parameters - case notifications - case includeElementDetails - case watchChildren - // CodingKey for new field - case filterCriteria - // Additional CodingKeys - case includeChildrenBrief - case includeChildrenInText - case includeIgnoredElements - } - - public init(commandId: String, - command: CommandType, - application: String? = nil, - attributes: [String]? = nil, - payload: [String: String]? = nil, - debugLogging: Bool = false, - locator: Locator? = nil, - pathHint: [String]? = nil, - maxElements: Int? = nil, - maxDepth: Int? = nil, - outputFormat: OutputFormat? = nil, - actionName: String? = nil, - actionValue: AnyCodable? = nil, - subCommands: [CommandEnvelope]? = nil, - point: CGPoint? = nil, - pid: Int? = nil, - notifications: [String]? = nil, - includeElementDetails: [String]? = nil, - watchChildren: Bool? = nil, - filterCriteria: [String: String]? = nil, - includeChildrenBrief: Bool? = nil, - includeChildrenInText: Bool? = nil, - includeIgnoredElements: Bool? = nil - ) { - self.commandId = commandId - self.command = command - self.application = application - self.attributes = attributes - self.payload = payload - self.debugLogging = debugLogging - self.locator = locator - self.pathHint = pathHint - self.maxElements = maxElements - self.maxDepth = maxDepth - self.outputFormat = outputFormat - self.actionName = actionName - self.actionValue = actionValue - self.subCommands = subCommands - self.point = point - self.pid = pid - self.notifications = notifications - self.includeElementDetails = includeElementDetails - self.watchChildren = watchChildren - self.filterCriteria = filterCriteria - self.includeChildrenBrief = includeChildrenBrief - self.includeChildrenInText = includeChildrenInText - self.includeIgnoredElements = includeIgnoredElements - } -} - -// Represents a single criterion for element matching -public struct Criterion: Codable, Sendable { - public let attribute: String - public let value: String - public let match_type: JSONPathHintComponent.MatchType? // Retained for flexibility if needed directly in Criterion - public let matchType: JSONPathHintComponent.MatchType? // Preferred name, aliased in custom init/codingkeys if needed - - // To handle decoding from either "match_type" or "matchType" - enum CodingKeys: String, CodingKey { - case attribute, value - case match_type // for decoding json - case matchType // for swift code - } - - public init(attribute: String, value: String, matchType: JSONPathHintComponent.MatchType? = nil) { - self.attribute = attribute - self.value = value - self.match_type = matchType // Set both to ensure consistency during encoding if old key is used - self.matchType = matchType - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - attribute = try container.decode(String.self, forKey: .attribute) - value = try container.decode(String.self, forKey: .value) - // Try decoding 'matchType' first, then fall back to 'match_type' - if let mt = try container.decodeIfPresent(JSONPathHintComponent.MatchType.self, forKey: .matchType) { - matchType = mt - match_type = mt - } else if let mtOld = try container.decodeIfPresent(JSONPathHintComponent.MatchType.self, forKey: .match_type) { - matchType = mtOld - match_type = mtOld - } else { - matchType = nil - match_type = nil - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(attribute, forKey: .attribute) - try container.encode(value, forKey: .value) - // Encode using the preferred 'matchType' key - try container.encodeIfPresent(matchType, forKey: .matchType) - } -} - -/// Represents a step in a hierarchical path, defined by a set of criteria. -public struct PathStep: Codable, Sendable { - public let criteria: [Criterion] - public let matchType: JSONPathHintComponent.MatchType? // How to evaluate criteria (e.g., exact, contains) - public let matchAllCriteria: Bool? // Whether all criteria must match (AND) or any (OR) - public let maxDepthForStep: Int? // Maximum depth to search for this specific step - - // CodingKeys to map JSON keys to Swift properties - enum CodingKeys: String, CodingKey { - case criteria - case matchType - case matchAllCriteria - case maxDepthForStep = "max_depth_for_step" // Map JSON's snake_case to Swift's camelCase - } - - // Default initializer - public init(criteria: [Criterion], - matchType: JSONPathHintComponent.MatchType? = .exact, - matchAllCriteria: Bool? = true, - maxDepthForStep: Int? = nil) { // Added maxDepthForStep - self.criteria = criteria - self.matchType = matchType - self.matchAllCriteria = matchAllCriteria - self.maxDepthForStep = maxDepthForStep // Initialize - } - - /// Returns a string representation suitable for logging - public func descriptionForLog() -> String { - let critDesc = criteria.map { criterion -> String in - "\(criterion.attribute):\(criterion.value)(\((criterion.matchType ?? .exact).rawValue))" - }.joined(separator: ", ") - - let depthStringPart: String - if let depth = maxDepthForStep { - depthStringPart = ", Depth: \(depth)" - } else { - depthStringPart = "" - } - - let matchTypeStringPart = (matchType ?? .exact).rawValue - let matchAllStringPart = "\(matchAllCriteria ?? true)" - - return "[Criteria: (\(critDesc)), MatchType: \(matchTypeStringPart), MatchAll: \(matchAllStringPart)\(depthStringPart)]" - } -} - -// Locator for finding elements -public struct Locator: Codable, Sendable { - public var matchAll: Bool? // For the top-level criteria, if path_from_root is not used or fails early. - public var criteria: [Criterion] - public var rootElementPathHint: [JSONPathHintComponent]? // Changed from [PathStep]? - public var descendantCriteria: [String: String]? // This seems to be an older/alternative way? Consider phasing out or clarifying. - public var requireAction: String? - public var computedNameContains: String? - public var debugPathSearch: Bool? - - enum CodingKeys: String, CodingKey { - case matchAll - case criteria - case rootElementPathHint = "path_from_root" // Map to JSON key "path_from_root" - case descendantCriteria - case requireAction - case computedNameContains - case debugPathSearch - } - - public init( - matchAll: Bool? = true, // Default to true for criteria - criteria: [Criterion] = [], - rootElementPathHint: [JSONPathHintComponent]? = nil, // Changed from [PathStep]? - descendantCriteria: [String: String]? = nil, - requireAction: String? = nil, - computedNameContains: String? = nil, - debugPathSearch: Bool? = false - ) { - self.matchAll = matchAll - self.criteria = criteria - self.rootElementPathHint = rootElementPathHint - self.descendantCriteria = descendantCriteria - self.requireAction = requireAction - self.computedNameContains = computedNameContains - self.debugPathSearch = debugPathSearch - } -} - -public enum CommandType: String, Codable, Sendable { - case ping - case query - case getAttributes - case describeElement - case getElementAtPoint - case getFocusedElement - case performAction - case batch - case observe - case collectAll - case stopObservation - case isProcessTrusted - case isAXFeatureEnabled - case setFocusedValue // Added from error - case extractText // Added from error - case setNotificationHandler // For AXObserver - case removeNotificationHandler // For AXObserver - case getElementDescription // Utility command for full description -} - -public enum OutputFormat: String, Codable, Sendable { - case json - case verbose - case smart // Default, tries to be concise and informative - case jsonString // JSON output as a string, often for AXpector. - case textContent // Specifically for text content output, might ignore non-textual parts. -} - -// MARK: - AnyCodable for mixed-type payloads or attributes - -// Reverted to simpler AnyCodable with public 'value' to match widespread usage -public struct AnyCodable: Codable, @unchecked Sendable { - public let value: Any - - public init(_ value: T?) { - self.value = value ?? () - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self.value = () - } else if let bool = try? container.decode(Bool.self) { - self.value = bool - } else if let int = try? container.decode(Int.self) { - self.value = int - } else if let double = try? container.decode(Double.self) { - self.value = double - } else if let string = try? container.decode(String.self) { - self.value = string - } else if let array = try? container.decode([AnyCodable].self) { - self.value = array.map { $0.value } - } else if let dictionary = try? container.decode([String: AnyCodable].self) { - self.value = dictionary.mapValues { $0.value } - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - if value is () { // Our nil marker for explicit nil - try container.encodeNil() - return - } - switch value { - case let bool as Bool: - try container.encode(bool) - case let int as Int: - try container.encode(int) - case let double as Double: - try container.encode(double) - case let string as String: - try container.encode(string) - case let array as [Any]: - try container.encode(array.map { AnyCodable($0) }) - case let dictionary as [String: Any]: - try container.encode(dictionary.mapValues { AnyCodable($0) }) - default: - if let codableValue = value as? Encodable { - // If the value conforms to Encodable, let it encode itself using the provided encoder. - // This is the most flexible approach as the Encodable type can use any container type it needs. - try codableValue.encode(to: encoder) - } else if CFGetTypeID(value as CFTypeRef) == CFNullGetTypeID() { - try container.encodeNil() - } else { - throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: [], debugDescription: "AnyCodable value (\(type(of: value))) cannot be encoded and does not conform to Encodable.")) - } - } - } -} - -// Helper struct for AnyCodable to properly encode intermediate Encodable values -// This might not be necessary if the direct (value as! Encodable).encode(to: encoder) works. -struct AnyCodablePośrednik: Encodable { - let value: T - init(_ value: T) { self.value = value } - func encode(to encoder: Encoder) throws { - try value.encode(to: encoder) - } -} - -// Helper protocol to check if a type is Optional -fileprivate protocol OptionalProtocol { - static func isOptional() -> Bool -} - -extension Optional: OptionalProtocol { - static func isOptional() -> Bool { - return true - } -} - -// MARK: - AXNotificationName enum -// Define AXNotificationName as a String-based enum for notification names -public enum AXNotificationName: String, Codable, Sendable { - case focusedUIElementChanged = "AXFocusedUIElementChanged" - case valueChanged = "AXValueChanged" - case uiElementDestroyed = "AXUIElementDestroyed" - case mainWindowChanged = "AXMainWindowChanged" - case focusedWindowChanged = "AXFocusedWindowChanged" - case applicationActivated = "AXApplicationActivated" - case applicationDeactivated = "AXApplicationDeactivated" - case applicationHidden = "AXApplicationHidden" - case applicationShown = "AXApplicationShown" - case windowCreated = "AXWindowCreated" - case windowResized = "AXWindowResized" - case windowMoved = "AXWindowMoved" - case announcementRequested = "AXAnnouncementRequested" - case focusedApplicationChanged = "AXFocusedApplicationChanged" - case focusedTabChanged = "AXFocusedTabChanged" - case windowMinimized = "AXWindowMiniaturized" - case windowDeminiaturized = "AXWindowDeminiaturized" - case sheetCreated = "AXSheetCreated" - case drawerCreated = "AXDrawerCreated" - case titleChanged = "AXTitleChanged" - case resized = "AXResized" - case moved = "AXMoved" - case created = "AXCreated" - case layoutChanged = "AXLayoutChanged" - case selectedTextChanged = "AXSelectedTextChanged" - case rowCountChanged = "AXRowCountChanged" - case selectedChildrenChanged = "AXSelectedChildrenChanged" - case selectedRowsChanged = "AXSelectedRowsChanged" - case selectedColumnsChanged = "AXSelectedColumnsChanged" - case rowExpanded = "AXRowExpanded" - case rowCollapsed = "AXRowCollapsed" - case selectedCellsChanged = "AXSelectedCellsChanged" - case helpTagCreated = "AXHelpTagCreated" - case loadComplete = "AXLoadComplete" -} - -// MARK: - AXCommand and Command Structs - -// Enum representing all possible AX commands -public enum AXCommand: Sendable { - case query(QueryCommand) - case performAction(PerformActionCommand) - case getAttributes(GetAttributesCommand) - case describeElement(DescribeElementCommand) - case extractText(ExtractTextCommand) - case batch(AXBatchCommand) - case setFocusedValue(SetFocusedValueCommand) - case getElementAtPoint(GetElementAtPointCommand) - case getFocusedElement(GetFocusedElementCommand) - case observe(ObserveCommand) - case collectAll(CollectAllCommand) - - // Computed property to get command type - public var type: String { - switch self { - case .query: return "query" - case .performAction: return "performAction" - case .getAttributes: return "getAttributes" - case .describeElement: return "describeElement" - case .extractText: return "extractText" - case .batch: return "batch" - case .setFocusedValue: return "setFocusedValue" - case .getElementAtPoint: return "getElementAtPoint" - case .getFocusedElement: return "getFocusedElement" - case .observe: return "observe" - case .collectAll: return "collectAll" - } - } -} - -// Command envelope for AXorcist -public struct AXCommandEnvelope: Sendable { - public let commandID: String - public let command: AXCommand - - public init(commandID: String, command: AXCommand) { - self.commandID = commandID - self.command = command - } -} - -// Individual command structs -public struct QueryCommand: Sendable { - public let appIdentifier: String? - public let locator: Locator - public let attributesToReturn: [String]? - public let maxDepthForSearch: Int - public let includeChildrenBrief: Bool? - - public init(appIdentifier: String?, locator: Locator, attributesToReturn: [String]? = nil, maxDepthForSearch: Int = 10, includeChildrenBrief: Bool? = nil) { - self.appIdentifier = appIdentifier - self.locator = locator - self.attributesToReturn = attributesToReturn - self.maxDepthForSearch = maxDepthForSearch - self.includeChildrenBrief = includeChildrenBrief - } -} - -public struct PerformActionCommand: Sendable { - public let appIdentifier: String? - public let locator: Locator - public let action: String - public let value: AnyCodable? - public let maxDepthForSearch: Int - - public init(appIdentifier: String?, locator: Locator, action: String, value: AnyCodable? = nil, maxDepthForSearch: Int = 10) { - self.appIdentifier = appIdentifier - self.locator = locator - self.action = action - self.value = value - self.maxDepthForSearch = maxDepthForSearch - } -} - -public struct GetAttributesCommand: Sendable { - public let appIdentifier: String? - public let locator: Locator - public let attributes: [String] - public let maxDepthForSearch: Int - - public init(appIdentifier: String?, locator: Locator, attributes: [String], maxDepthForSearch: Int = 10) { - self.appIdentifier = appIdentifier - self.locator = locator - self.attributes = attributes - self.maxDepthForSearch = maxDepthForSearch - } -} - -public struct DescribeElementCommand: Sendable { - public let appIdentifier: String? - public let locator: Locator - public let formatOption: ValueFormatOption - public let maxDepthForSearch: Int - public let depth: Int - public let includeIgnored: Bool - public let maxSearchDepth: Int - - public init(appIdentifier: String?, locator: Locator, formatOption: ValueFormatOption = .smart, maxDepthForSearch: Int = 10, depth: Int = 3, includeIgnored: Bool = false, maxSearchDepth: Int = 10) { - self.appIdentifier = appIdentifier - self.locator = locator - self.formatOption = formatOption - self.maxDepthForSearch = maxDepthForSearch - self.depth = depth - self.includeIgnored = includeIgnored - self.maxSearchDepth = maxSearchDepth - } -} - -public struct ExtractTextCommand: Sendable { - public let appIdentifier: String? - public let locator: Locator - public let maxDepthForSearch: Int - public let includeChildren: Bool? - public let maxDepth: Int? - - public init(appIdentifier: String?, locator: Locator, maxDepthForSearch: Int = 10, includeChildren: Bool? = nil, maxDepth: Int? = nil) { - self.appIdentifier = appIdentifier - self.locator = locator - self.maxDepthForSearch = maxDepthForSearch - self.includeChildren = includeChildren - self.maxDepth = maxDepth - } -} - -public struct SetFocusedValueCommand: Sendable { - public let appIdentifier: String? - public let locator: Locator - public let value: String - public let maxDepthForSearch: Int - - public init(appIdentifier: String?, locator: Locator, value: String, maxDepthForSearch: Int = 10) { - self.appIdentifier = appIdentifier - self.locator = locator - self.value = value - self.maxDepthForSearch = maxDepthForSearch - } -} - -public struct GetElementAtPointCommand: Sendable { - public let point: CGPoint - public let appIdentifier: String? - public let pid: Int? - public let x: Float - public let y: Float - public let attributesToReturn: [String]? - public let includeChildrenBrief: Bool? - - public init(point: CGPoint, appIdentifier: String? = nil, pid: Int? = nil, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) { - self.point = point - self.appIdentifier = appIdentifier - self.pid = pid - self.x = Float(point.x) - self.y = Float(point.y) - self.attributesToReturn = attributesToReturn - self.includeChildrenBrief = includeChildrenBrief - } - - public init(appIdentifier: String?, x: Float, y: Float, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) { - self.point = CGPoint(x: CGFloat(x), y: CGFloat(y)) - self.appIdentifier = appIdentifier - self.pid = nil - self.x = x - self.y = y - self.attributesToReturn = attributesToReturn - self.includeChildrenBrief = includeChildrenBrief - } -} - -public struct GetFocusedElementCommand: Sendable { - public let appIdentifier: String? - public let attributesToReturn: [String]? - public let includeChildrenBrief: Bool? - - public init(appIdentifier: String?, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) { - self.appIdentifier = appIdentifier - self.attributesToReturn = attributesToReturn - self.includeChildrenBrief = includeChildrenBrief - } -} - -public struct ObserveCommand: Sendable { - public let appIdentifier: String? - public let locator: Locator? - public let notifications: [String] - public let includeDetails: Bool - public let watchChildren: Bool - public let notificationName: AXNotification - public let includeElementDetails: [String]? - public let maxDepthForSearch: Int - - public init(appIdentifier: String?, locator: Locator? = nil, notifications: [String], includeDetails: Bool = true, watchChildren: Bool = false, notificationName: AXNotification, includeElementDetails: [String]? = nil, maxDepthForSearch: Int = 10) { - self.appIdentifier = appIdentifier - self.locator = locator - self.notifications = notifications - self.includeDetails = includeDetails - self.watchChildren = watchChildren - self.notificationName = notificationName - self.includeElementDetails = includeElementDetails - self.maxDepthForSearch = maxDepthForSearch - } -} - -// Command struct for collectAll -public struct CollectAllCommand: Sendable { - public let appIdentifier: String? - public let attributesToReturn: [String]? - public let maxDepth: Int - public let filterCriteria: [String: String]? // JSON string for criteria, or can be decoded - public let valueFormatOption: ValueFormatOption? - - public init( - appIdentifier: String? = nil, // Provide default nil - attributesToReturn: [String]? = nil, - maxDepth: Int = 10, - filterCriteria: [String: String]? = nil, - valueFormatOption: ValueFormatOption? = .smart - ) { - self.appIdentifier = appIdentifier - self.attributesToReturn = attributesToReturn - self.maxDepth = maxDepth - self.filterCriteria = filterCriteria - self.valueFormatOption = valueFormatOption - } -} - -// Batch command structures -public struct AXBatchCommand: Sendable { - public struct SubCommandEnvelope: Sendable { - public let commandID: String - public let command: AXCommand - - public init(commandID: String, command: AXCommand) { - self.commandID = commandID - self.command = command - } - } - - public let commands: [SubCommandEnvelope] - - public init(commands: [SubCommandEnvelope]) { - self.commands = commands - } -} - -// Alias for backward compatibility if needed -public typealias AXSubCommand = AXCommand -public typealias BatchCommandEnvelope = AXBatchCommand +// CommandModels.swift - This file has been split into multiple files for better organization +// +// The types previously in this file have been moved to: +// - ValueFormatOption.swift +// - MatchingTypes.swift (Criterion, PathStep, Locator) +// - CommandTypes.swift (CommandType, OutputFormat) +// - NotificationTypes.swift (AXNotificationName) +// - AnyCodable.swift +// - CommandEnvelope.swift +// - AXCommands.swift (AXCommand and related command structs) +// +// This file is kept empty for backward compatibility. \ No newline at end of file diff --git a/Sources/AXorcist/Core/CommandTypes.swift b/Sources/AXorcist/Core/CommandTypes.swift new file mode 100644 index 0000000..f7f67bd --- /dev/null +++ b/Sources/AXorcist/Core/CommandTypes.swift @@ -0,0 +1,32 @@ +// CommandTypes.swift - Command type definitions + +import Foundation + +public enum CommandType: String, Codable, Sendable { + case ping + case query + case getAttributes + case describeElement + case getElementAtPoint + case getFocusedElement + case performAction + case batch + case observe + case collectAll + case stopObservation + case isProcessTrusted + case isAXFeatureEnabled + case setFocusedValue // Added from error + case extractText // Added from error + case setNotificationHandler // For AXObserver + case removeNotificationHandler // For AXObserver + case getElementDescription // Utility command for full description +} + +public enum OutputFormat: String, Codable, Sendable { + case json + case verbose + case smart // Default, tries to be concise and informative + case jsonString // JSON output as a string, often for AXpector. + case textContent // Specifically for text content output, might ignore non-textual parts. +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/MatchingTypes.swift b/Sources/AXorcist/Core/MatchingTypes.swift new file mode 100644 index 0000000..cc3f859 --- /dev/null +++ b/Sources/AXorcist/Core/MatchingTypes.swift @@ -0,0 +1,135 @@ +// MatchingTypes.swift - Types for element matching and locating + +import Foundation + +// Represents a single criterion for element matching +public struct Criterion: Codable, Sendable { + public let attribute: String + public let value: String + public let match_type: JSONPathHintComponent.MatchType? // Retained for flexibility if needed directly in Criterion + public let matchType: JSONPathHintComponent.MatchType? // Preferred name, aliased in custom init/codingkeys if needed + + // To handle decoding from either "match_type" or "matchType" + enum CodingKeys: String, CodingKey { + case attribute, value + case match_type // for decoding json + case matchType // for swift code + } + + public init(attribute: String, value: String, matchType: JSONPathHintComponent.MatchType? = nil) { + self.attribute = attribute + self.value = value + self.match_type = matchType // Set both to ensure consistency during encoding if old key is used + self.matchType = matchType + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + attribute = try container.decode(String.self, forKey: .attribute) + value = try container.decode(String.self, forKey: .value) + // Try decoding 'matchType' first, then fall back to 'match_type' + if let mt = try container.decodeIfPresent(JSONPathHintComponent.MatchType.self, forKey: .matchType) { + matchType = mt + match_type = mt + } else if let mtOld = try container.decodeIfPresent(JSONPathHintComponent.MatchType.self, forKey: .match_type) { + matchType = mtOld + match_type = mtOld + } else { + matchType = nil + match_type = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(attribute, forKey: .attribute) + try container.encode(value, forKey: .value) + // Encode using the preferred 'matchType' key + try container.encodeIfPresent(matchType, forKey: .matchType) + } +} + +/// Represents a step in a hierarchical path, defined by a set of criteria. +public struct PathStep: Codable, Sendable { + public let criteria: [Criterion] + public let matchType: JSONPathHintComponent.MatchType? // How to evaluate criteria (e.g., exact, contains) + public let matchAllCriteria: Bool? // Whether all criteria must match (AND) or any (OR) + public let maxDepthForStep: Int? // Maximum depth to search for this specific step + + // CodingKeys to map JSON keys to Swift properties + enum CodingKeys: String, CodingKey { + case criteria + case matchType + case matchAllCriteria + case maxDepthForStep = "max_depth_for_step" // Map JSON's snake_case to Swift's camelCase + } + + // Default initializer + public init(criteria: [Criterion], + matchType: JSONPathHintComponent.MatchType? = .exact, + matchAllCriteria: Bool? = true, + maxDepthForStep: Int? = nil) { // Added maxDepthForStep + self.criteria = criteria + self.matchType = matchType + self.matchAllCriteria = matchAllCriteria + self.maxDepthForStep = maxDepthForStep // Initialize + } + + /// Returns a string representation suitable for logging + public func descriptionForLog() -> String { + let critDesc = criteria.map { criterion -> String in + "\(criterion.attribute):\(criterion.value)(\((criterion.matchType ?? .exact).rawValue))" + }.joined(separator: ", ") + + let depthStringPart: String + if let depth = maxDepthForStep { + depthStringPart = ", Depth: \(depth)" + } else { + depthStringPart = "" + } + + let matchTypeStringPart = (matchType ?? .exact).rawValue + let matchAllStringPart = "\(matchAllCriteria ?? true)" + + return "[Criteria: (\(critDesc)), MatchType: \(matchTypeStringPart), MatchAll: \(matchAllStringPart)\(depthStringPart)]" + } +} + +// Locator for finding elements +public struct Locator: Codable, Sendable { + public var matchAll: Bool? // For the top-level criteria, if path_from_root is not used or fails early. + public var criteria: [Criterion] + public var rootElementPathHint: [JSONPathHintComponent]? // Changed from [PathStep]? + public var descendantCriteria: [String: String]? // This seems to be an older/alternative way? Consider phasing out or clarifying. + public var requireAction: String? + public var computedNameContains: String? + public var debugPathSearch: Bool? + + enum CodingKeys: String, CodingKey { + case matchAll + case criteria + case rootElementPathHint = "path_from_root" // Map to JSON key "path_from_root" + case descendantCriteria + case requireAction + case computedNameContains + case debugPathSearch + } + + public init( + matchAll: Bool? = true, // Default to true for criteria + criteria: [Criterion] = [], + rootElementPathHint: [JSONPathHintComponent]? = nil, // Changed from [PathStep]? + descendantCriteria: [String: String]? = nil, + requireAction: String? = nil, + computedNameContains: String? = nil, + debugPathSearch: Bool? = false + ) { + self.matchAll = matchAll + self.criteria = criteria + self.rootElementPathHint = rootElementPathHint + self.descendantCriteria = descendantCriteria + self.requireAction = requireAction + self.computedNameContains = computedNameContains + self.debugPathSearch = debugPathSearch + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/NotificationTypes.swift b/Sources/AXorcist/Core/NotificationTypes.swift new file mode 100644 index 0000000..2f8f8b0 --- /dev/null +++ b/Sources/AXorcist/Core/NotificationTypes.swift @@ -0,0 +1,42 @@ +// NotificationTypes.swift - AX notification type definitions + +import Foundation + +// MARK: - AXNotificationName enum +// Define AXNotificationName as a String-based enum for notification names +public enum AXNotificationName: String, Codable, Sendable { + case focusedUIElementChanged = "AXFocusedUIElementChanged" + case valueChanged = "AXValueChanged" + case uiElementDestroyed = "AXUIElementDestroyed" + case mainWindowChanged = "AXMainWindowChanged" + case focusedWindowChanged = "AXFocusedWindowChanged" + case applicationActivated = "AXApplicationActivated" + case applicationDeactivated = "AXApplicationDeactivated" + case applicationHidden = "AXApplicationHidden" + case applicationShown = "AXApplicationShown" + case windowCreated = "AXWindowCreated" + case windowResized = "AXWindowResized" + case windowMoved = "AXWindowMoved" + case announcementRequested = "AXAnnouncementRequested" + case focusedApplicationChanged = "AXFocusedApplicationChanged" + case focusedTabChanged = "AXFocusedTabChanged" + case windowMinimized = "AXWindowMiniaturized" + case windowDeminiaturized = "AXWindowDeminiaturized" + case sheetCreated = "AXSheetCreated" + case drawerCreated = "AXDrawerCreated" + case titleChanged = "AXTitleChanged" + case resized = "AXResized" + case moved = "AXMoved" + case created = "AXCreated" + case layoutChanged = "AXLayoutChanged" + case selectedTextChanged = "AXSelectedTextChanged" + case rowCountChanged = "AXRowCountChanged" + case selectedChildrenChanged = "AXSelectedChildrenChanged" + case selectedRowsChanged = "AXSelectedRowsChanged" + case selectedColumnsChanged = "AXSelectedColumnsChanged" + case rowExpanded = "AXRowExpanded" + case rowCollapsed = "AXRowCollapsed" + case selectedCellsChanged = "AXSelectedCellsChanged" + case helpTagCreated = "AXHelpTagCreated" + case loadComplete = "AXLoadComplete" +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/NotificationWatcher.swift b/Sources/AXorcist/Core/NotificationWatcher.swift index 0952168..b18367a 100644 --- a/Sources/AXorcist/Core/NotificationWatcher.swift +++ b/Sources/AXorcist/Core/NotificationWatcher.swift @@ -8,7 +8,7 @@ public class NotificationWatcher { private let target: ObservationTarget private let notification: AXNotification private let handler: AXNotificationSubscriptionHandler - private var subscriptionToken: AXObserverCenter.SubscriptionToken? + private var subscriptionToken: SubscriptionToken? private var isObserving: Bool = false private enum ObservationTarget { diff --git a/Sources/AXorcist/Core/ObserverHelpers.swift b/Sources/AXorcist/Core/ObserverHelpers.swift new file mode 100644 index 0000000..edcbc22 --- /dev/null +++ b/Sources/AXorcist/Core/ObserverHelpers.swift @@ -0,0 +1,54 @@ +// ObserverHelpers.swift - Helper functions for AXObserver operations + +import ApplicationServices +import Foundation + +// MARK: - Helper for userInfo conversion +@MainActor +internal func convertCFValueToSwift(_ cfValue: CFTypeRef?) -> Any? { + guard let cfValue = cfValue else { return nil } + let typeID = CFGetTypeID(cfValue) + + switch typeID { + case CFStringGetTypeID(): + return cfValue as? String + case CFNumberGetTypeID(): + return cfValue as? NSNumber // Could be Int, Double, Bool (via NSNumber bridging) + case CFBooleanGetTypeID(): + // Ensure correct conversion for CFBoolean + if CFEqual(cfValue, kCFBooleanTrue) { + return true + } else if CFEqual(cfValue, kCFBooleanFalse) { + return false + } + // Fallback for other CFBoolean representations if any, or if direct Bool bridging works + if let boolVal = cfValue as? Bool { + return boolVal + } + axWarningLog("Could not convert CFBoolean to Bool: \(String(describing: cfValue))") + return nil // Or handle as error + case CFArrayGetTypeID(): + // Swift arrays bridge to CFArray, and CFArray can be cast to NSArray / [AnyObject] + if let cfArray = cfValue as? [CFTypeRef] { // or cfValue as? NSArray + return cfArray.compactMap { convertCFValueToSwift($0) } + } + axWarningLog("Failed to convert CFArray from userInfo.") + return cfValue // Return raw CFArray if conversion fails for some reason + case CFDictionaryGetTypeID(): + if let cfDict = cfValue as? [CFString: CFTypeRef] { // or cfValue as? NSDictionary + var swiftDict = [String: Any]() + for (key, value) in cfDict { + swiftDict[key as String] = convertCFValueToSwift(value) + } + return swiftDict + } + axWarningLog("Failed to convert nested CFDictionary from userInfo.") + return cfValue // Return raw CFDictionary if conversion fails + case AXUIElementGetTypeID(): + return cfValue as! AXUIElement // Should be safe to force unwrap if type matches + // Add other common CF types if necessary, e.g., CFURL, CFDate + default: + axDebugLog("Unhandled CFTypeRef in convertCFValueToSwift: typeID \(typeID). Value: \(cfValue)") + return cfValue // Return raw CFTypeRef if unhandled, caller might know what to do + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/ObserverTypes.swift b/Sources/AXorcist/Core/ObserverTypes.swift new file mode 100644 index 0000000..081b24f --- /dev/null +++ b/Sources/AXorcist/Core/ObserverTypes.swift @@ -0,0 +1,34 @@ +// ObserverTypes.swift - Types and structs for AXObserver management + +import ApplicationServices +import Foundation + +/// Callback type for observer notifications +// public typealias AXObserverHandler = @MainActor (pid_t, AXNotification, AXObserver, AXUIElement, CFDictionary?) -> Void // Old handler + +/// New callback type for subscriptions. The AXObserver and AXUIElement might be less relevant to the direct subscriber +/// if the Center abstracts them, or they can be added back if deemed necessary. +public typealias AXNotificationSubscriptionHandler = @MainActor (/*element: Element,*/ pid_t, AXNotification, _ rawElement: AXUIElement, _ nsUserInfo: [String: Any]?) -> Void + +/// Key for tracking registered notifications. Can allow nil PID for global observers for a specific notification type. +public struct AXNotificationSubscriptionKey: Hashable { + let pid: pid_t? // Optional to allow for global observers for a specific notification + let notification: AXNotification +} + +/// Key and PID pair for tracking registered notifications +public struct AXObserverKeyAndPID: Hashable { + let pid: pid_t + let key: AXNotification +} + +/// Observer and PID pair for tracking active observers +public struct AXObserverObjAndPID { + var observer: AXObserver + var pid: pid_t +} + +/// Public token for unsubscribing +public struct SubscriptionToken: Hashable { + let id: UUID +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/ValueFormatOption.swift b/Sources/AXorcist/Core/ValueFormatOption.swift new file mode 100644 index 0000000..3ab79f7 --- /dev/null +++ b/Sources/AXorcist/Core/ValueFormatOption.swift @@ -0,0 +1,11 @@ +// ValueFormatOption.swift - Enum for specifying value formatting + +import Foundation + +// Enum for specifying how values, especially for descriptions, should be formatted. +public enum ValueFormatOption: String, Codable, Sendable { + case smart // Tries to provide the most useful, possibly summarized, representation. + case raw // Provides the raw or complete value, potentially verbose. + case textContent // Specifically for text content extraction, might ignore non-textual parts. + case stringified // For detailed string representation, often for logging or debugging. +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/AttributeBuilders.swift b/Sources/AXorcist/Search/AttributeBuilders.swift new file mode 100644 index 0000000..4fa5d96 --- /dev/null +++ b/Sources/AXorcist/Search/AttributeBuilders.swift @@ -0,0 +1,132 @@ +// AttributeBuilders.swift - Functions for building attribute collections + +import ApplicationServices +import CoreGraphics +import Foundation + +// MARK: - Attribute Collection Builders + +@MainActor +internal func addBasicAttributes(to attributes: inout [String: AnyCodable], element: Element) async { + if let role = element.role() { + attributes[AXAttributeNames.kAXRoleAttribute] = AnyCodable(role) + } + if let subrole = element.subrole() { + attributes[AXAttributeNames.kAXSubroleAttribute] = AnyCodable(subrole) + } + if let title = element.title() { + attributes[AXAttributeNames.kAXTitleAttribute] = AnyCodable(title) + } + if let descriptionText = element.descriptionText() { + attributes[AXAttributeNames.kAXDescriptionAttribute] = AnyCodable(descriptionText) + } + if let value = element.value() { + attributes[AXAttributeNames.kAXValueAttribute] = AnyCodable(value) + } + if let help = element.attribute(Attribute(AXAttributeNames.kAXHelpAttribute)) { + attributes[AXAttributeNames.kAXHelpAttribute] = AnyCodable(help) + } + if let placeholder = element.attribute(Attribute(AXAttributeNames.kAXPlaceholderValueAttribute)) { + attributes[AXAttributeNames.kAXPlaceholderValueAttribute] = AnyCodable(placeholder) + } +} + +@MainActor +internal func addStateAttributes(to attributes: inout [String: AnyCodable], element: Element) async { + attributes[AXAttributeNames.kAXEnabledAttribute] = AnyCodable(element.isEnabled()) + attributes[AXAttributeNames.kAXFocusedAttribute] = AnyCodable(element.isFocused()) + attributes[AXAttributeNames.kAXHiddenAttribute] = AnyCodable(element.isHidden()) + attributes[AXMiscConstants.isIgnoredAttributeKey] = AnyCodable(element.isIgnored()) + attributes[AXAttributeNames.kAXElementBusyAttribute] = AnyCodable(element.isElementBusy()) +} + +@MainActor +internal func addGeometryAttributes(to attributes: inout [String: AnyCodable], element: Element) async { + if let position = element.attribute(Attribute(AXAttributeNames.kAXPositionAttribute)) { + attributes[AXAttributeNames.kAXPositionAttribute] = AnyCodable(NSPointToDictionary(position)) + } + if let size = element.attribute(Attribute(AXAttributeNames.kAXSizeAttribute)) { + attributes[AXAttributeNames.kAXSizeAttribute] = AnyCodable(NSSizeToDictionary(size)) + } +} + +@MainActor +internal func addHierarchyAttributes(to attributes: inout [String: AnyCodable], element: Element, valueFormatOption: ValueFormatOption) async { + if let parent = element.parent() { + attributes[AXAttributeNames.kAXParentAttribute] = AnyCodable( + parent.briefDescription(option: .raw) + ) + } + if let children = element.children() { + attributes[AXAttributeNames.kAXChildrenAttribute] = AnyCodable( + children.map { $0.briefDescription(option: .raw) } + ) + } +} + +@MainActor +internal func addActionAttributes(to attributes: inout [String: AnyCodable], element: Element) async { + var actionsToStore: [String]? + + if let currentActions = element.supportedActions(), !currentActions.isEmpty { + actionsToStore = currentActions + } else if let fallbackActions: [String] = element.attribute( + Attribute<[String]>(AXAttributeNames.kAXActionsAttribute) + ), !fallbackActions.isEmpty { + actionsToStore = fallbackActions + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Used fallback kAXActionsAttribute for \(element.briefDescription(option: .raw))")) + } + + attributes[AXAttributeNames.kAXActionsAttribute] = actionsToStore != nil + ? AnyCodable(actionsToStore) + : AnyCodable(nil as [String]?) + + if element.isActionSupported(AXActionNames.kAXPressAction) { + attributes["\(AXActionNames.kAXPressAction)_Supported"] = AnyCodable(true) + } +} + +@MainActor +internal func addStandardStringAttributes(to attributes: inout [String: AnyCodable], element: Element) async { + let standardAttributes = [ + AXAttributeNames.kAXRoleDescriptionAttribute, + AXAttributeNames.kAXValueDescriptionAttribute, + AXAttributeNames.kAXIdentifierAttribute + ] + + for attrName in standardAttributes { + if attributes[attrName] == nil, + let attrValue: String = element.attribute(Attribute(attrName)) { + attributes[attrName] = AnyCodable(attrValue) + } + } +} + +@MainActor +internal func addStoredAttributes(to attributes: inout [String: AnyCodable], element: Element) { + guard let stored = element.attributes else { return } + + for (key, val) in stored where attributes[key] == nil { + attributes[key] = val + } +} + +@MainActor +internal func addComputedProperties(to attributes: inout [String: AnyCodable], element: Element) async { + if attributes[AXMiscConstants.computedNameAttributeKey] == nil, + let name = element.computedName() { + attributes[AXMiscConstants.computedNameAttributeKey] = AnyCodable(name) + } + + if attributes[AXMiscConstants.computedPathAttributeKey] == nil { + attributes[AXMiscConstants.computedPathAttributeKey] = AnyCodable(element.generatePathString()) + } + + if attributes[AXMiscConstants.isClickableAttributeKey] == nil { + let isButton = element.role() == AXRoleNames.kAXButtonRole + let hasPressAction = element.isActionSupported(AXActionNames.kAXPressAction) + if isButton || hasPressAction { + attributes[AXMiscConstants.isClickableAttributeKey] = AnyCodable(true) + } + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/AttributeExtractors.swift b/Sources/AXorcist/Search/AttributeExtractors.swift new file mode 100644 index 0000000..8e92b92 --- /dev/null +++ b/Sources/AXorcist/Search/AttributeExtractors.swift @@ -0,0 +1,157 @@ +// AttributeExtractors.swift - Low-level attribute extraction logic + +import ApplicationServices +import Foundation + +// MARK: - Internal Fetch Logic Helpers + +// Approach using direct property access within a switch statement +@MainActor +internal func extractDirectPropertyValue( + for attributeName: String, + from element: Element, + outputFormat: OutputFormat +) -> (value: Any?, handled: Bool) { + var extractedValue: Any? + var handled = true + + switch attributeName { + case AXAttributeNames.kAXPathHintAttribute: + extractedValue = element.attribute(Attribute(AXAttributeNames.kAXPathHintAttribute)) + case AXAttributeNames.kAXRoleAttribute: + extractedValue = element.role() + case AXAttributeNames.kAXSubroleAttribute: + extractedValue = element.subrole() + case AXAttributeNames.kAXTitleAttribute: + extractedValue = element.title() + case AXAttributeNames.kAXDescriptionAttribute: + extractedValue = element.descriptionText() // Renamed + case AXAttributeNames.kAXEnabledAttribute: + let val = element.isEnabled() + extractedValue = val + if outputFormat == .textContent { + extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString + } + case AXAttributeNames.kAXFocusedAttribute: + let val = element.isFocused() + extractedValue = val + if outputFormat == .textContent { + extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString + } + case AXAttributeNames.kAXHiddenAttribute: + let val = element.isHidden() + extractedValue = val + if outputFormat == .textContent { + extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString + } + case AXMiscConstants.isIgnoredAttributeKey: + let val = element.isIgnored() + extractedValue = val + if outputFormat == .textContent { + extractedValue = val ? "true" : "false" + } + case "PID": + let val = element.pid() + extractedValue = val + if outputFormat == .textContent { + extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString + } + case AXAttributeNames.kAXElementBusyAttribute: + let val = element.isElementBusy() + extractedValue = val + if outputFormat == .textContent { + extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString + } + default: + handled = false + } + return (extractedValue, handled) +} + +@MainActor +internal func determineAttributesToFetch( + requestedAttributes: [String]?, + forMultiDefault: Bool, + targetRole: String?, + element: Element +) -> [String] { + var attributesToFetch = requestedAttributes ?? [] + if forMultiDefault { + attributesToFetch = [ + AXAttributeNames.kAXRoleAttribute, + AXAttributeNames.kAXValueAttribute, + AXAttributeNames.kAXTitleAttribute, + AXAttributeNames.kAXIdentifierAttribute + ] + if let role = targetRole, role == AXRoleNames.kAXStaticTextRole { + attributesToFetch = [ + AXAttributeNames.kAXRoleAttribute, + AXAttributeNames.kAXValueAttribute, + AXAttributeNames.kAXIdentifierAttribute + ] + } + } else if attributesToFetch.isEmpty { + if requestedAttributes == nil || requestedAttributes!.isEmpty { + // If no specific attributes are requested, decide what to do based on context + // This part of the logic for deciding what to fetch if nothing specific is requested + // has been simplified or might be intended to be expanded. + // For now, if forMultiDefault is true, it implies fetching a default set (e.g., for multi-element views) + // otherwise, it might fetch all or a basic set. + // This example assumes if not forMultiDefault, and no specifics, it fetches all available. + if !forMultiDefault { + // Example: Fetch all attribute names if none are specified and not for a multi-default scenario + if let names = element.attributeNames() { + attributesToFetch.append(contentsOf: names) + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "determineAttributesToFetch: No specific attributes requested, fetched all \(names.count) available: \(names.joined(separator: ", "))")) + } else { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: + "determineAttributesToFetch: No specific attributes requested and " + + "failed to fetch all available names." + )) + } + } else { + // For multi-default, or if the above block doesn't execute, + // it might rely on a predefined default set or do nothing further here, + // letting subsequent logic handle AXorcist.defaultAttributesToFetch if attributesToFetch remains empty. + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: + "determineAttributesToFetch: No specific attributes requested. Using defaults or context-specific set." + )) + } + } + } + return attributesToFetch +} + +// Function to get specifically computed attributes for an element +@MainActor +internal func getComputedAttributes(for element: Element) async -> [String: AttributeData] { + var computedAttrs: [String: AttributeData] = [:] + + if let name = element.computedName() { + computedAttrs[AXMiscConstants.computedNameAttributeKey] = AttributeData( + value: AnyCodable(name), + source: .computed + ) + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: + "getComputedAttributes: Computed name for element " + + "\(element.briefDescription(option: .raw)) is '\(name)'." + )) + } else { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: + "getComputedAttributes: Element \(element.briefDescription(option: .raw)) " + + "has no computed name." + )) + } + + // Placeholder for other future purely computed attributes if needed + // For example, isClickable could also be added here if not handled elsewhere: + // let isButton = (element.role() == AXRoleNames.kAXButtonRole) + // let hasPressAction = element.isActionSupported(AXActionNames.kAXPressAction) + // if isButton || hasPressAction { + // computedAttrs[AXMiscConstants.isClickableAttributeKey] = AttributeData( + // value: AnyCodable(true), source: .computed + // ) + // } + + return computedAttrs +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/AttributeFormatters.swift b/Sources/AXorcist/Search/AttributeFormatters.swift new file mode 100644 index 0000000..f645adc --- /dev/null +++ b/Sources/AXorcist/Search/AttributeFormatters.swift @@ -0,0 +1,166 @@ +// AttributeFormatters.swift - Attribute formatting logic + +import ApplicationServices +import CoreGraphics +import Foundation + +// Helper for formatting raw CFTypeRef values for .textContent output +@MainActor +internal func formatRawCFValueForTextContent(_ rawValue: CFTypeRef?) async -> String { + guard let value = rawValue else { return AXMiscConstants.kAXNotAvailableString } + let typeID = CFGetTypeID(value) + if typeID == CFStringGetTypeID() { + return (value as! String) + } else if typeID == CFAttributedStringGetTypeID() { + return (value as! NSAttributedString).string + } else if typeID == AXValueGetTypeID() { + let axVal = value as! AXValue + return formatAXValue(axVal, option: ValueFormatOption.smart) + } else if typeID == CFNumberGetTypeID() { + return (value as! NSNumber).stringValue + } else if typeID == CFBooleanGetTypeID() { + return CFBooleanGetValue((value as! CFBoolean)) ? "true" : "false" + } else { + let typeDesc = CFCopyTypeIDDescription(typeID) as String? ?? "ComplexType" + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: + "formatRawCFValueForTextContent: Encountered unhandled CFTypeID \(typeID) - " + + "\(typeDesc). Returning placeholder." + )) + return "<\(typeDesc)>" + } +} + +@MainActor +internal func extractAndFormatAttribute( + element: Element, + attributeName: String, + outputFormat: OutputFormat, + valueFormatOption: ValueFormatOption +) async -> AnyCodable? { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "extractAndFormatAttribute: '\(attributeName)' for element \(element.briefDescription(option: .raw))")) + + // Try to extract using known attribute handlers first + if let extractedValue = await extractKnownAttribute(element: element, attributeName: attributeName, outputFormat: outputFormat) { + return AnyCodable(extractedValue) + } + + // Fallback to raw attribute value + return await extractRawAttribute(element: element, attributeName: attributeName, outputFormat: outputFormat) +} + +@MainActor +private func extractKnownAttribute(element: Element, attributeName: String, outputFormat: OutputFormat) async -> Any? { + switch attributeName { + case AXAttributeNames.kAXPathHintAttribute: + return element.attribute(Attribute(AXAttributeNames.kAXPathHintAttribute)) + case AXAttributeNames.kAXRoleAttribute: + return element.role() + case AXAttributeNames.kAXSubroleAttribute: + return element.subrole() + case AXAttributeNames.kAXTitleAttribute: + return element.title() + case AXAttributeNames.kAXDescriptionAttribute: + return element.descriptionText() + case AXAttributeNames.kAXEnabledAttribute: + return formatBooleanAttribute(element.isEnabled(), outputFormat: outputFormat) + case AXAttributeNames.kAXFocusedAttribute: + return formatBooleanAttribute(element.isFocused(), outputFormat: outputFormat) + case AXAttributeNames.kAXHiddenAttribute: + return formatBooleanAttribute(element.isHidden(), outputFormat: outputFormat) + case AXMiscConstants.isIgnoredAttributeKey: + let val = element.isIgnored() + return outputFormat == .textContent ? (val ? "true" : "false") : val + case "PID": + return formatOptionalIntAttribute(element.pid(), outputFormat: outputFormat) + case AXAttributeNames.kAXElementBusyAttribute: + return formatBooleanAttribute(element.isElementBusy(), outputFormat: outputFormat) + default: + return nil + } +} + +@MainActor +private func formatBooleanAttribute(_ value: Bool?, outputFormat: OutputFormat) -> Any? { + guard let val = value else { return nil } + return outputFormat == .textContent ? val.description : val +} + +@MainActor +private func formatOptionalIntAttribute(_ value: Int32?, outputFormat: OutputFormat) -> Any? { + guard let val = value else { return nil } + return outputFormat == .textContent ? val.description : val +} + +@MainActor +private func extractRawAttribute(element: Element, attributeName: String, outputFormat: OutputFormat) async -> AnyCodable? { + let rawCFValue = element.rawAttributeValue(named: attributeName) + + if outputFormat == .textContent { + let formatted = await formatRawCFValueForTextContent(rawCFValue) + return AnyCodable(formatted) + } + + guard let unwrapped = ValueUnwrapper.unwrap(rawCFValue) else { + // Only log if rawCFValue was not nil initially + if rawCFValue != nil { + let cfTypeID = String(describing: CFGetTypeID(rawCFValue!)) + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: + "extractAndFormatAttribute: '\(attributeName)' was non-nil CFTypeRef " + + "but unwrapped to nil. CFTypeID: \(cfTypeID)" + )) + return AnyCodable("") + } + return nil + } + + return AnyCodable(unwrapped) +} + +@MainActor +internal func formatParentAttribute( + _ parent: Element?, + outputFormat: OutputFormat, + valueFormatOption: ValueFormatOption +) async -> AnyCodable { + guard let parentElement = parent else { return AnyCodable(nil as String?) } + if outputFormat == .textContent { + return AnyCodable("Element: \(parentElement.role() ?? "?Role")") + } else { + return AnyCodable(parentElement.briefDescription(option: .raw)) + } +} + +@MainActor +internal func formatChildrenAttribute( + _ children: [Element]?, + outputFormat: OutputFormat, + valueFormatOption: ValueFormatOption +) async -> AnyCodable { + guard let actualChildren = children, !actualChildren.isEmpty else { + return AnyCodable(nil as String?) + } + if outputFormat == .textContent { + var childrenSummaries: [String] = [] + for childElement in actualChildren { + childrenSummaries.append(childElement.briefDescription(option: .raw)) + } + return AnyCodable("[\(childrenSummaries.joined(separator: ", "))]") + } else { + let childrenDescriptions = actualChildren.map { $0.briefDescription(option: .raw) } + return AnyCodable(childrenDescriptions) + } +} + +@MainActor +internal func formatFocusedUIElementAttribute( + _ focusedElement: Element?, + outputFormat: OutputFormat, + valueFormatOption: ValueFormatOption +) async -> AnyCodable { + guard let element = focusedElement else { return AnyCodable(nil as String?) } + if outputFormat == .textContent { + return AnyCodable("Focused: \(element.role() ?? "?Role") - \(element.title() ?? "?Title")") + } else { + return AnyCodable(element.briefDescription(option: .raw)) + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/AttributeHelpers.swift b/Sources/AXorcist/Search/AttributeHelpers.swift index 48358a3..8ef5108 100644 --- a/Sources/AXorcist/Search/AttributeHelpers.swift +++ b/Sources/AXorcist/Search/AttributeHelpers.swift @@ -1,182 +1,9 @@ -// AttributeHelpers.swift - Contains functions for fetching and formatting element attributes +// AttributeHelpers.swift - Main public API for attribute fetching and formatting -import ApplicationServices // For AXUIElement related types -import CoreGraphics // For potential future use with geometry types from attributes +import ApplicationServices +import CoreGraphics import Foundation -// ElementDetails struct for AXpector -public struct ElementDetails { - public var title: String? - public var role: String? - public var roleDescription: String? - public var value: Any? - public var help: Any? - public var isIgnored: Bool - public var actions: [String]? - public var isClickable: Bool - public var computedName: String? - - public init() { - self.isIgnored = false - self.isClickable = false - } -} - -// Enum to specify the source of an attribute -public enum AttributeSource: String, Codable { - case direct // Directly from AXUIElement - case computed // Computed by AXorcist (e.g., path, name heuristic) - case prefetched // From element's stored attributes dictionary -} - -// Struct to hold attribute data along with its source -public struct AttributeData: Codable { - public let value: AnyCodable - public let source: AttributeSource -} - -// Helper functions to convert CoreGraphics types to dictionaries for JSON serialization -// These are needed because AnyCodable might not handle them directly as dictionaries. -func NSPointToDictionary(_ point: CGPoint) -> [String: CGFloat] { - return ["x": point.x, "y": point.y] -} - -func NSSizeToDictionary(_ size: CGSize) -> [String: CGFloat] { - return ["width": size.width, "height": size.height] -} - -func NSRectToDictionary(_ rect: CGRect) -> [String: Any] { // Changed to Any for origin/size - return [ - "x": rect.origin.x, - "y": rect.origin.y, - "width": rect.size.width, - "height": rect.size.height - ] -} - -// MARK: - Element Summary Helpers - -// Removed getSingleElementSummary as it was unused. - -// MARK: - Internal Fetch Logic Helpers - -// Approach using direct property access within a switch statement -@MainActor -private func extractDirectPropertyValue( - for attributeName: String, - from element: Element, - outputFormat: OutputFormat -) -> (value: Any?, handled: Bool) { - var extractedValue: Any? - var handled = true - - switch attributeName { - case AXAttributeNames.kAXPathHintAttribute: - extractedValue = element.attribute(Attribute(AXAttributeNames.kAXPathHintAttribute)) - case AXAttributeNames.kAXRoleAttribute: - extractedValue = element.role() - case AXAttributeNames.kAXSubroleAttribute: - extractedValue = element.subrole() - case AXAttributeNames.kAXTitleAttribute: - extractedValue = element.title() - case AXAttributeNames.kAXDescriptionAttribute: - extractedValue = element.descriptionText() // Renamed - case AXAttributeNames.kAXEnabledAttribute: - let val = element.isEnabled() - extractedValue = val - if outputFormat == .textContent { - extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString - } - case AXAttributeNames.kAXFocusedAttribute: - let val = element.isFocused() - extractedValue = val - if outputFormat == .textContent { - extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString - } - case AXAttributeNames.kAXHiddenAttribute: - let val = element.isHidden() - extractedValue = val - if outputFormat == .textContent { - extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString - } - case AXMiscConstants.isIgnoredAttributeKey: - let val = element.isIgnored() - extractedValue = val - if outputFormat == .textContent { - extractedValue = val ? "true" : "false" - } - case "PID": - let val = element.pid() - extractedValue = val - if outputFormat == .textContent { - extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString - } - case AXAttributeNames.kAXElementBusyAttribute: - let val = element.isElementBusy() - extractedValue = val - if outputFormat == .textContent { - extractedValue = val?.description ?? AXMiscConstants.kAXNotAvailableString - } - default: - handled = false - } - return (extractedValue, handled) -} - -@MainActor -private func determineAttributesToFetch( - requestedAttributes: [String]?, - forMultiDefault: Bool, - targetRole: String?, - element: Element -) -> [String] { - var attributesToFetch = requestedAttributes ?? [] - if forMultiDefault { - attributesToFetch = [ - AXAttributeNames.kAXRoleAttribute, - AXAttributeNames.kAXValueAttribute, - AXAttributeNames.kAXTitleAttribute, - AXAttributeNames.kAXIdentifierAttribute - ] - if let role = targetRole, role == AXRoleNames.kAXStaticTextRole { - attributesToFetch = [ - AXAttributeNames.kAXRoleAttribute, - AXAttributeNames.kAXValueAttribute, - AXAttributeNames.kAXIdentifierAttribute - ] - } - } else if attributesToFetch.isEmpty { - if requestedAttributes == nil || requestedAttributes!.isEmpty { - // If no specific attributes are requested, decide what to do based on context - // This part of the logic for deciding what to fetch if nothing specific is requested - // has been simplified or might be intended to be expanded. - // For now, if forMultiDefault is true, it implies fetching a default set (e.g., for multi-element views) - // otherwise, it might fetch all or a basic set. - // This example assumes if not forMultiDefault, and no specifics, it fetches all available. - if !forMultiDefault { - // Example: Fetch all attribute names if none are specified and not for a multi-default scenario - if let names = element.attributeNames() { - attributesToFetch.append(contentsOf: names) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "determineAttributesToFetch: No specific attributes requested, fetched all \(names.count) available: \(names.joined(separator: ", "))")) - } else { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: - "determineAttributesToFetch: No specific attributes requested and " + - "failed to fetch all available names." - )) - } - } else { - // For multi-default, or if the above block doesn't execute, - // it might rely on a predefined default set or do nothing further here, - // letting subsequent logic handle AXorcist.defaultAttributesToFetch if attributesToFetch remains empty. - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: - "determineAttributesToFetch: No specific attributes requested. Using defaults or context-specific set." - )) - } - } - } - return attributesToFetch -} - // MARK: - Public Attribute Getters @MainActor @@ -301,154 +128,6 @@ public func getAllElementDataForAXpector( return (attributes, elementDetails) } -// Function to get specifically computed attributes for an element -@MainActor -internal func getComputedAttributes(for element: Element) async -> [String: AttributeData] { - var computedAttrs: [String: AttributeData] = [:] - - if let name = element.computedName() { - computedAttrs[AXMiscConstants.computedNameAttributeKey] = AttributeData( - value: AnyCodable(name), - source: .computed - ) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: - "getComputedAttributes: Computed name for element " + - "\(element.briefDescription(option: .raw)) is '\(name)'." - )) - } else { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: - "getComputedAttributes: Element \(element.briefDescription(option: .raw)) " + - "has no computed name." - )) - } - - // Placeholder for other future purely computed attributes if needed - // For example, isClickable could also be added here if not handled elsewhere: - // let isButton = (element.role() == AXRoleNames.kAXButtonRole) - // let hasPressAction = element.isActionSupported(AXActionNames.kAXPressAction) - // if isButton || hasPressAction { - // computedAttrs[AXMiscConstants.isClickableAttributeKey] = AttributeData( - // value: AnyCodable(true), source: .computed - // ) - // } - - return computedAttrs -} - -// Helper for formatting raw CFTypeRef values for .textContent output -@MainActor -internal func formatRawCFValueForTextContent(_ rawValue: CFTypeRef?) async -> String { - guard let value = rawValue else { return AXMiscConstants.kAXNotAvailableString } - let typeID = CFGetTypeID(value) - if typeID == CFStringGetTypeID() { - return (value as! String) - } else if typeID == CFAttributedStringGetTypeID() { - return (value as! NSAttributedString).string - } else if typeID == AXValueGetTypeID() { - let axVal = value as! AXValue - return formatAXValue(axVal, option: ValueFormatOption.smart) - } else if typeID == CFNumberGetTypeID() { - return (value as! NSNumber).stringValue - } else if typeID == CFBooleanGetTypeID() { - return CFBooleanGetValue((value as! CFBoolean)) ? "true" : "false" - } else { - let typeDesc = CFCopyTypeIDDescription(typeID) as String? ?? "ComplexType" - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: - "formatRawCFValueForTextContent: Encountered unhandled CFTypeID \(typeID) - " + - "\(typeDesc). Returning placeholder." - )) - return "<\(typeDesc)>" - } -} - -// formatAXValue is now defined in Values/AXValueSpecificFormatter.swift - -@MainActor -internal func extractAndFormatAttribute( - element: Element, - attributeName: String, - outputFormat: OutputFormat, - valueFormatOption: ValueFormatOption -) async -> AnyCodable? { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "extractAndFormatAttribute: '\(attributeName)' for element \(element.briefDescription(option: .raw))")) - - // Try to extract using known attribute handlers first - if let extractedValue = await extractKnownAttribute(element: element, attributeName: attributeName, outputFormat: outputFormat) { - return AnyCodable(extractedValue) - } - - // Fallback to raw attribute value - return await extractRawAttribute(element: element, attributeName: attributeName, outputFormat: outputFormat) -} - -@MainActor -private func extractKnownAttribute(element: Element, attributeName: String, outputFormat: OutputFormat) async -> Any? { - switch attributeName { - case AXAttributeNames.kAXPathHintAttribute: - return element.attribute(Attribute(AXAttributeNames.kAXPathHintAttribute)) - case AXAttributeNames.kAXRoleAttribute: - return element.role() - case AXAttributeNames.kAXSubroleAttribute: - return element.subrole() - case AXAttributeNames.kAXTitleAttribute: - return element.title() - case AXAttributeNames.kAXDescriptionAttribute: - return element.descriptionText() - case AXAttributeNames.kAXEnabledAttribute: - return formatBooleanAttribute(element.isEnabled(), outputFormat: outputFormat) - case AXAttributeNames.kAXFocusedAttribute: - return formatBooleanAttribute(element.isFocused(), outputFormat: outputFormat) - case AXAttributeNames.kAXHiddenAttribute: - return formatBooleanAttribute(element.isHidden(), outputFormat: outputFormat) - case AXMiscConstants.isIgnoredAttributeKey: - let val = element.isIgnored() - return outputFormat == .textContent ? (val ? "true" : "false") : val - case "PID": - return formatOptionalIntAttribute(element.pid(), outputFormat: outputFormat) - case AXAttributeNames.kAXElementBusyAttribute: - return formatBooleanAttribute(element.isElementBusy(), outputFormat: outputFormat) - default: - return nil - } -} - -@MainActor -private func formatBooleanAttribute(_ value: Bool?, outputFormat: OutputFormat) -> Any? { - guard let val = value else { return nil } - return outputFormat == .textContent ? val.description : val -} - -@MainActor -private func formatOptionalIntAttribute(_ value: Int32?, outputFormat: OutputFormat) -> Any? { - guard let val = value else { return nil } - return outputFormat == .textContent ? val.description : val -} - -@MainActor -private func extractRawAttribute(element: Element, attributeName: String, outputFormat: OutputFormat) async -> AnyCodable? { - let rawCFValue = element.rawAttributeValue(named: attributeName) - - if outputFormat == .textContent { - let formatted = await formatRawCFValueForTextContent(rawCFValue) - return AnyCodable(formatted) - } - - guard let unwrapped = ValueUnwrapper.unwrap(rawCFValue) else { - // Only log if rawCFValue was not nil initially - if rawCFValue != nil { - let cfTypeID = String(describing: CFGetTypeID(rawCFValue!)) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: - "extractAndFormatAttribute: '\(attributeName)' was non-nil CFTypeRef " + - "but unwrapped to nil. CFTypeID: \(cfTypeID)" - )) - return AnyCodable("") - } - return nil - } - - return AnyCodable(unwrapped) -} - @MainActor public func getElementFullDescription( element: Element, @@ -483,218 +162,4 @@ public func getElementFullDescription( "\(element.briefDescription(option: .raw)). Returning \(attributes.count) attributes." )) return (attributes, []) -} - -@MainActor -private func addBasicAttributes(to attributes: inout [String: AnyCodable], element: Element) async { - if let role = element.role() { - attributes[AXAttributeNames.kAXRoleAttribute] = AnyCodable(role) - } - if let subrole = element.subrole() { - attributes[AXAttributeNames.kAXSubroleAttribute] = AnyCodable(subrole) - } - if let title = element.title() { - attributes[AXAttributeNames.kAXTitleAttribute] = AnyCodable(title) - } - if let descriptionText = element.descriptionText() { - attributes[AXAttributeNames.kAXDescriptionAttribute] = AnyCodable(descriptionText) - } - if let value = element.value() { - attributes[AXAttributeNames.kAXValueAttribute] = AnyCodable(value) - } - if let help = element.attribute(Attribute(AXAttributeNames.kAXHelpAttribute)) { - attributes[AXAttributeNames.kAXHelpAttribute] = AnyCodable(help) - } - if let placeholder = element.attribute(Attribute(AXAttributeNames.kAXPlaceholderValueAttribute)) { - attributes[AXAttributeNames.kAXPlaceholderValueAttribute] = AnyCodable(placeholder) - } -} - -@MainActor -private func addStateAttributes(to attributes: inout [String: AnyCodable], element: Element) async { - attributes[AXAttributeNames.kAXEnabledAttribute] = AnyCodable(element.isEnabled()) - attributes[AXAttributeNames.kAXFocusedAttribute] = AnyCodable(element.isFocused()) - attributes[AXAttributeNames.kAXHiddenAttribute] = AnyCodable(element.isHidden()) - attributes[AXMiscConstants.isIgnoredAttributeKey] = AnyCodable(element.isIgnored()) - attributes[AXAttributeNames.kAXElementBusyAttribute] = AnyCodable(element.isElementBusy()) -} - -@MainActor -private func addGeometryAttributes(to attributes: inout [String: AnyCodable], element: Element) async { - if let position = element.attribute(Attribute(AXAttributeNames.kAXPositionAttribute)) { - attributes[AXAttributeNames.kAXPositionAttribute] = AnyCodable(NSPointToDictionary(position)) - } - if let size = element.attribute(Attribute(AXAttributeNames.kAXSizeAttribute)) { - attributes[AXAttributeNames.kAXSizeAttribute] = AnyCodable(NSSizeToDictionary(size)) - } -} - -@MainActor -private func addHierarchyAttributes(to attributes: inout [String: AnyCodable], element: Element, valueFormatOption: ValueFormatOption) async { - if let parent = element.parent() { - attributes[AXAttributeNames.kAXParentAttribute] = AnyCodable( - parent.briefDescription(option: .raw) - ) - } - if let children = element.children() { - attributes[AXAttributeNames.kAXChildrenAttribute] = AnyCodable( - children.map { $0.briefDescription(option: .raw) } - ) - } -} - -@MainActor -private func addActionAttributes(to attributes: inout [String: AnyCodable], element: Element) async { - var actionsToStore: [String]? - - if let currentActions = element.supportedActions(), !currentActions.isEmpty { - actionsToStore = currentActions - } else if let fallbackActions: [String] = element.attribute( - Attribute<[String]>(AXAttributeNames.kAXActionsAttribute) - ), !fallbackActions.isEmpty { - actionsToStore = fallbackActions - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Used fallback kAXActionsAttribute for \(element.briefDescription(option: .raw))")) - } - - attributes[AXAttributeNames.kAXActionsAttribute] = actionsToStore != nil - ? AnyCodable(actionsToStore) - : AnyCodable(nil as [String]?) - - if element.isActionSupported(AXActionNames.kAXPressAction) { - attributes["\(AXActionNames.kAXPressAction)_Supported"] = AnyCodable(true) - } -} - -@MainActor -private func addStandardStringAttributes(to attributes: inout [String: AnyCodable], element: Element) async { - let standardAttributes = [ - AXAttributeNames.kAXRoleDescriptionAttribute, - AXAttributeNames.kAXValueDescriptionAttribute, - AXAttributeNames.kAXIdentifierAttribute - ] - - for attrName in standardAttributes { - if attributes[attrName] == nil, - let attrValue: String = element.attribute(Attribute(attrName)) { - attributes[attrName] = AnyCodable(attrValue) - } - } -} - -@MainActor -private func addStoredAttributes(to attributes: inout [String: AnyCodable], element: Element) { - guard let stored = element.attributes else { return } - - for (key, val) in stored where attributes[key] == nil { - attributes[key] = val - } -} - -@MainActor -private func addComputedProperties(to attributes: inout [String: AnyCodable], element: Element) async { - if attributes[AXMiscConstants.computedNameAttributeKey] == nil, - let name = element.computedName() { - attributes[AXMiscConstants.computedNameAttributeKey] = AnyCodable(name) - } - - if attributes[AXMiscConstants.computedPathAttributeKey] == nil { - attributes[AXMiscConstants.computedPathAttributeKey] = AnyCodable(element.generatePathString()) - } - - if attributes[AXMiscConstants.isClickableAttributeKey] == nil { - let isButton = element.role() == AXRoleNames.kAXButtonRole - let hasPressAction = element.isActionSupported(AXActionNames.kAXPressAction) - if isButton || hasPressAction { - attributes[AXMiscConstants.isClickableAttributeKey] = AnyCodable(true) - } - } -} - -@MainActor -private func formatParentAttribute( - _ parent: Element?, - outputFormat: OutputFormat, - valueFormatOption: ValueFormatOption -) async -> AnyCodable { - guard let parentElement = parent else { return AnyCodable(nil as String?) } - if outputFormat == .textContent { - return AnyCodable("Element: \(parentElement.role() ?? "?Role")") - } else { - return AnyCodable(parentElement.briefDescription(option: .raw)) - } -} - -@MainActor -private func formatChildrenAttribute( - _ children: [Element]?, - outputFormat: OutputFormat, - valueFormatOption: ValueFormatOption -) async -> AnyCodable { - guard let actualChildren = children, !actualChildren.isEmpty else { - return AnyCodable(nil as String?) - } - if outputFormat == .textContent { - var childrenSummaries: [String] = [] - for childElement in actualChildren { - childrenSummaries.append(childElement.briefDescription(option: .raw)) - } - return AnyCodable("[\(childrenSummaries.joined(separator: ", "))]") - } else { - let childrenDescriptions = actualChildren.map { $0.briefDescription(option: .raw) } - return AnyCodable(childrenDescriptions) - } -} - -@MainActor -private func formatFocusedUIElementAttribute( - _ focusedElement: Element?, - outputFormat: OutputFormat, - valueFormatOption: ValueFormatOption -) async -> AnyCodable { - guard let element = focusedElement else { return AnyCodable(nil as String?) } - if outputFormat == .textContent { - return AnyCodable("Focused: \(element.role() ?? "?Role") - \(element.title() ?? "?Title")") - } else { - return AnyCodable(element.briefDescription(option: .raw)) - } -} - -// formatValue is likely not needed anymore if ValueUnwrapper is robust and -// extractAndFormatAttribute handles types correctly. -// Keeping it commented out for now, can be removed if confirmed. -/* - @MainActor - func formatValue(_ value: Any?, outputFormat: OutputFormat, valueFormatOption: ValueFormatOption) -> AnyCodable? { - guard let val = value else { return nil } - - if outputFormat == .textContent { - if let strVal = val as? String { return AnyCodable(strVal) } - if let attrStrVal = val as? NSAttributedString { return AnyCodable(attrStrVal.string) } - if let boolVal = val as? Bool { return AnyCodable(boolVal.description) } - if let numVal = val as? NSNumber { return AnyCodable(numVal.stringValue) } - // For other complex types, a generic description - return AnyCodable("<".appending(String(describing: type(of: val))).appending(">")) - } - - // For JSON or other structured output, try to preserve type or use AnyCodable - if let axVal = val as? AXValue { // AXValue might not be directly Codable - return AnyCodable(formatAXValue(axVal, option: valueFormatOption)) - } else if let elementVal = val as? Element { // Element might not be directly Codable in all contexts - return AnyCodable(elementVal.briefDescription(option: valueFormatOption)) - } else if let arrayVal = val as? [Any?] { - return AnyCodable( - arrayVal.map { - formatValue($0, outputFormat: outputFormat, valueFormatOption: valueFormatOption)?.value - } - ) // Recursively format - } else if let dictVal = val as? [String: Any?] { - return AnyCodable( - dictVal.mapValues { - formatValue($0, outputFormat: outputFormat, valueFormatOption: valueFormatOption)?.value - } - ) - } - - return AnyCodable(val) // Fallback to AnyCodable directly - } - */ +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/AttributeTypes.swift b/Sources/AXorcist/Search/AttributeTypes.swift new file mode 100644 index 0000000..9a0ee91 --- /dev/null +++ b/Sources/AXorcist/Search/AttributeTypes.swift @@ -0,0 +1,34 @@ +// AttributeTypes.swift - Core types for attribute handling + +import Foundation + +// ElementDetails struct for AXpector +public struct ElementDetails { + public var title: String? + public var role: String? + public var roleDescription: String? + public var value: Any? + public var help: Any? + public var isIgnored: Bool + public var actions: [String]? + public var isClickable: Bool + public var computedName: String? + + public init() { + self.isIgnored = false + self.isClickable = false + } +} + +// Enum to specify the source of an attribute +public enum AttributeSource: String, Codable { + case direct // Directly from AXUIElement + case computed // Computed by AXorcist (e.g., path, name heuristic) + case prefetched // From element's stored attributes dictionary +} + +// Struct to hold attribute data along with its source +public struct AttributeData: Codable { + public let value: AnyCodable + public let source: AttributeSource +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/GeometryHelpers.swift b/Sources/AXorcist/Search/GeometryHelpers.swift new file mode 100644 index 0000000..78f4e3c --- /dev/null +++ b/Sources/AXorcist/Search/GeometryHelpers.swift @@ -0,0 +1,23 @@ +// GeometryHelpers.swift - Helper functions for geometry type conversions + +import CoreGraphics +import Foundation + +// Helper functions to convert CoreGraphics types to dictionaries for JSON serialization +// These are needed because AnyCodable might not handle them directly as dictionaries. +func NSPointToDictionary(_ point: CGPoint) -> [String: CGFloat] { + return ["x": point.x, "y": point.y] +} + +func NSSizeToDictionary(_ size: CGSize) -> [String: CGFloat] { + return ["width": size.width, "height": size.height] +} + +func NSRectToDictionary(_ rect: CGRect) -> [String: Any] { // Changed to Any for origin/size + return [ + "x": rect.origin.x, + "y": rect.origin.y, + "width": rect.size.width, + "height": rect.size.height + ] +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/PathNavigationCore.swift b/Sources/AXorcist/Search/PathNavigationCore.swift new file mode 100644 index 0000000..3be8848 --- /dev/null +++ b/Sources/AXorcist/Search/PathNavigationCore.swift @@ -0,0 +1,86 @@ +// PathNavigationCore.swift - Core path navigation functions + +import ApplicationServices +import Foundation + +// MARK: - Core Navigation Functions + +@MainActor +internal func navigateToElement( + from startElement: Element, + pathHint: [String], + maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch +) -> Element? { + var currentElement = startElement + var currentPathSegmentForLog = "" + + for (index, pathComponentString) in pathHint.enumerated() { + currentPathSegmentForLog += (index > 0 ? " -> " : "") + pathComponentString + + if index == 0 && pathComponentString.lowercased() == "application" { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Path component 'application' encountered. Using current element (app root) as context for next component.")) + continue + } + + if index >= maxDepth { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation aborted: Path hint index \(index) reached maxDepth \(maxDepth). Path so far: \(currentPathSegmentForLog)")) + return nil + } + + let criteriaToMatch = PathUtils.parseRichPathComponent(pathComponentString) + guard !criteriaToMatch.isEmpty else { + GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "CRITICAL_NAV_PARSE_FAILURE_MARKER: Empty or unparsable criteria from pathComponentString '\(pathComponentString)'")) + return nil + } + + if let nextElement = processPathComponent( + currentElement: currentElement, + pathComponentString: pathComponentString, + criteriaToMatch: criteriaToMatch, + currentPathSegmentForLog: currentPathSegmentForLog + ) { + currentElement = nextElement + } else { + return nil + } + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation successful. Final element: \(currentElement.briefDescription(option: ValueFormatOption.smart))")) + return currentElement +} + +@MainActor +internal func processPathComponent( + currentElement: Element, + pathComponentString: String, + criteriaToMatch: [String: String], + currentPathSegmentForLog: String +) -> Element? { + let currentElementDescForLog = currentElement.briefDescription(option: ValueFormatOption.smart) + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Processing path component '\(pathComponentString)' at element [\(currentElementDescForLog)]. Path: \(currentPathSegmentForLog)")) + + if let matchingChild = findMatchingChild(parentElement: currentElement, criteriaToMatch: criteriaToMatch, pathComponentForLog: pathComponentString) { + return matchingChild + } + + if elementMatchesAllCriteria(currentElement, criteria: criteriaToMatch, forPathComponent: pathComponentString) { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Path component '\(pathComponentString)' matches current element [\(currentElementDescForLog)].")) + return currentElement + } + + logNoMatchFound(currentElement: currentElement, pathComponentString: pathComponentString, criteriaToMatch: criteriaToMatch, currentPathSegmentForLog: currentPathSegmentForLog) + return nil +} + +@MainActor +internal func getChildrenFromElement(_ element: Element) -> [Element]? { + guard let children = element.children() else { + let currentElementDescForLog = element.briefDescription(option: ValueFormatOption.smart) + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\(currentElementDescForLog)] has no children (returned nil for .children()).")) + return nil + } + if children.isEmpty { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\(element.briefDescription(option: ValueFormatOption.smart))] has zero children (returned empty array for .children()).")) + } + return children +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/PathNavigationJSON.swift b/Sources/AXorcist/Search/PathNavigationJSON.swift new file mode 100644 index 0000000..3dbf8df --- /dev/null +++ b/Sources/AXorcist/Search/PathNavigationJSON.swift @@ -0,0 +1,190 @@ +// PathNavigationJSON.swift - JSON path hint navigation + +import ApplicationServices +import Foundation +import Logging + +// Define logger for this file +private let logger = Logger(label: "AXorcist.PathNavigationJSON") + +// MARK: - JSON PathHint Navigation + +@MainActor +internal func navigateToElementByJSONPathHint( + from startElement: Element, + jsonPathHint: [JSONPathHintComponent], + overallMaxDepth: Int = AXMiscConstants.defaultMaxDepthSearch, + initialPathSegmentForLog: String = "Application" +) -> Element? { + var currentElement = startElement + var currentPathSegmentForLog = initialPathSegmentForLog + + for (index, pathComponent) in jsonPathHint.enumerated() { + let componentLogString = pathComponent.descriptionForLog() + currentPathSegmentForLog += " -> " + componentLogString + if pathComponent.attribute.lowercased() == "application" { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JPHN: JSON path component \(index) is 'application'. Using current element (app root) as context for next component.")) + continue + } + + if index >= overallMaxDepth { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JPHN: Navigation aborted: JSON path hint index \(index) reached overallMaxDepth \(overallMaxDepth). Path so far: \(currentPathSegmentForLog)")) + return nil + } + + if let nextElement = processJSONPathComponent( + currentElement: currentElement, + pathComponent: pathComponent, + currentPathSegmentForLog: currentPathSegmentForLog, + componentLogString: componentLogString + ) { + currentElement = nextElement + } else { + return nil + } + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/JPHN: Navigation successful. Final element: [\(currentElement.briefDescription(option: ValueFormatOption.smart))]. Full path: \(currentPathSegmentForLog)")) + return currentElement +} + +@MainActor +private func processJSONPathComponent( + currentElement: Element, + pathComponent: JSONPathHintComponent, + currentPathSegmentForLog: String, + componentLogString: String +) -> Element? { + let currentElementDescForLog = currentElement.briefDescription(option: ValueFormatOption.smart) + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JPHN: Processing JSON path component '\(componentLogString)' at element [\(currentElementDescForLog)]. Path: \(currentPathSegmentForLog)")) + + let criteriaToMatch = convertJSONPathComponentToCriteria(pathComponent) + let actualMatchType = pathComponent.matchType ?? .exact + let actualMaxDepthForSearch = pathComponent.depth ?? 1 + + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JPHN: Converted JSON component to criteria: \(criteriaToMatch). MatchType: \(actualMatchType.rawValue), MaxDepthForSearch: \(actualMaxDepthForSearch)")) + + if actualMaxDepthForSearch > 1 { + if let deepMatch = findMatchRecursively( + in: currentElement, + criteria: criteriaToMatch, + matchType: actualMatchType, + maxDepth: actualMaxDepthForSearch, + pathComponentForLog: componentLogString + ) { + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/JPHN: Deep match found for component '\(componentLogString)': [\(deepMatch.briefDescription(option: ValueFormatOption.smart))]")) + return deepMatch + } + } else { + if let directChild = findMatchingChildJSON( + parentElement: currentElement, + criteriaToMatch: criteriaToMatch, + matchType: actualMatchType, + pathComponentForLog: componentLogString + ) { + return directChild + } + + if elementMatchesAllCriteriaJSON(currentElement, criteria: criteriaToMatch, matchType: actualMatchType, forPathComponent: componentLogString) { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JPHN: JSON path component '\(componentLogString)' matches current element [\(currentElementDescForLog)].")) + return currentElement + } + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/JPHN: JSON path component '\(componentLogString)' with criteria \(criteriaToMatch) did not match any child or current element [\(currentElementDescForLog)]. Path so far: \(currentPathSegmentForLog). Search depth was \(actualMaxDepthForSearch).")) + return nil +} + +@MainActor +private func convertJSONPathComponentToCriteria(_ component: JSONPathHintComponent) -> [String: String] { + // Use the component's simpleCriteria property which handles the attribute mapping + return component.simpleCriteria ?? [:] +} + +@MainActor +private func findMatchingChildJSON( + parentElement: Element, + criteriaToMatch: [String: String], + matchType: JSONPathHintComponent.MatchType, + pathComponentForLog: String +) -> Element? { + guard let children = getChildrenFromElement(parentElement) else { + return nil + } + + for (childIndex, child) in children.enumerated() { + if elementMatchesAllCriteriaJSON(child, criteria: criteriaToMatch, matchType: matchType, forPathComponent: pathComponentForLog) { + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMCJ: Found matching child at index \(childIndex) for JSON component [\(pathComponentForLog)]: [\(child.briefDescription(option: ValueFormatOption.smart))].")) + return child + } + } + + return nil +} + +@MainActor +private func elementMatchesAllCriteriaJSON( + _ element: Element, + criteria: [String: String], + matchType: JSONPathHintComponent.MatchType, + forPathComponent pathComponentForLog: String +) -> Bool { + if criteria.isEmpty { + return true + } + + for (key, expectedValue) in criteria { + let criterionDidMatch = matchSingleCriterion( + element: element, + key: key, + expectedValue: expectedValue, + matchType: matchType, + elementDescriptionForLog: element.briefDescription(option: ValueFormatOption.smart) + ) + + if !criterionDidMatch { + return false + } + } + + return true +} + +@MainActor +private func findMatchRecursively( + in rootElement: Element, + criteria: [String: String], + matchType: JSONPathHintComponent.MatchType, + maxDepth: Int, + pathComponentForLog: String +) -> Element? { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMR: Starting recursive search for component '\(pathComponentForLog)' with maxDepth \(maxDepth) from [\(rootElement.briefDescription(option: ValueFormatOption.smart))]")) + + var queue: [(element: Element, depth: Int)] = [(rootElement, 0)] + var visited = Set() + + while !queue.isEmpty { + let (currentElement, currentDepth) = queue.removeFirst() + + if visited.contains(currentElement) { + continue + } + visited.insert(currentElement) + + if elementMatchesAllCriteriaJSON(currentElement, criteria: criteria, matchType: matchType, forPathComponent: pathComponentForLog) { + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMR: Found match at depth \(currentDepth): [\(currentElement.briefDescription(option: ValueFormatOption.smart))]")) + return currentElement + } + + if currentDepth < maxDepth { + if let children = currentElement.children() { + for child in children { + queue.append((child, currentDepth + 1)) + } + } + } + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMR: No match found in recursive search for component '\(pathComponentForLog)'")) + return nil +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/PathNavigationMatching.swift b/Sources/AXorcist/Search/PathNavigationMatching.swift new file mode 100644 index 0000000..d11f695 --- /dev/null +++ b/Sources/AXorcist/Search/PathNavigationMatching.swift @@ -0,0 +1,76 @@ +// PathNavigationMatching.swift - Element matching functions for path navigation + +import ApplicationServices +import Foundation + +// MARK: - Element Matching + +// New helper to check if an element matches all given criteria +@MainActor +internal func elementMatchesAllCriteria( + _ element: Element, + criteria: [String: String], + forPathComponent pathComponentForLog: String // For logging +) -> Bool { + let elementDescriptionForLog = element.briefDescription(option: ValueFormatOption.smart) + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_START: Checking element [\(elementDescriptionForLog)] for component [\(pathComponentForLog)]. Criteria: \(criteria)")) + + if criteria.isEmpty { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Criteria empty for component [\(pathComponentForLog)]. Element [\(elementDescriptionForLog)] considered a match by default.")) + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] MATCHED (empty criteria) for component [\(pathComponentForLog)].")) + return true + } + + for (key, expectedValue) in criteria { + let matchTypeForKey: JSONPathHintComponent.MatchType = (key.lowercased() == AXAttributeNames.kAXDOMClassListAttribute.lowercased()) ? .contains : .exact + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC_CRITERION: Checking criterion '\(key): \(expectedValue)' (matchType: \(matchTypeForKey.rawValue)) on element [\(elementDescriptionForLog)] for component [\(pathComponentForLog)].")) + + let criterionDidMatch = matchSingleCriterion(element: element, key: key, expectedValue: expectedValue, matchType: matchTypeForKey, elementDescriptionForLog: elementDescriptionForLog) + let message = "PathNav/EMAC_CRITERION_RESULT: Criterion '\(key): \(expectedValue)' on [\(elementDescriptionForLog)] for [\(pathComponentForLog)]: \(criterionDidMatch ? "MATCHED" : "FAILED")" + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: message)) + + if !criterionDidMatch { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\(elementDescriptionForLog)] FAILED to match criterion '\(key): \(expectedValue)' for component [\(pathComponentForLog)].")) + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] FAILED for component [\(pathComponentForLog)].")) + return false + } + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\(elementDescriptionForLog)] successfully MATCHED ALL criteria for component [\(pathComponentForLog)].")) + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] MATCHED ALL criteria for component [\(pathComponentForLog)].")) + return true +} + +@MainActor +internal func findMatchingChild( + parentElement: Element, + criteriaToMatch: [String: String], + pathComponentForLog: String +) -> Element? { + guard let children = getChildrenFromElement(parentElement) else { + return nil + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Searching for matching child among \(children.count) children of [\(parentElement.briefDescription(option: ValueFormatOption.smart))] for component [\(pathComponentForLog)].")) + + for (childIndex, child) in children.enumerated() { + if elementMatchesAllCriteria(child, criteria: criteriaToMatch, forPathComponent: pathComponentForLog) { + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC: Found matching child at index \(childIndex) for component [\(pathComponentForLog)]: [\(child.briefDescription(option: ValueFormatOption.smart))].")) + return child + } + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: No matching child found for component [\(pathComponentForLog)] among \(children.count) children.")) + return nil +} + +@MainActor +internal func logNoMatchFound( + currentElement: Element, + pathComponentString: String, + criteriaToMatch: [String: String], + currentPathSegmentForLog: String +) { + let currentElementDescForLog = currentElement.briefDescription(option: ValueFormatOption.smart) + GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "Path component '\(pathComponentString)' with criteria \(criteriaToMatch) did not match any child or current element [\(currentElementDescForLog)]. Path so far: \(currentPathSegmentForLog)")) +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/PathNavigationUtilities.swift b/Sources/AXorcist/Search/PathNavigationUtilities.swift new file mode 100644 index 0000000..c8186f0 --- /dev/null +++ b/Sources/AXorcist/Search/PathNavigationUtilities.swift @@ -0,0 +1,132 @@ +// PathNavigationUtilities.swift - Utility functions for path navigation + +import AppKit +import ApplicationServices +import Foundation +import Logging + +// Define logger for this file +private let logger = Logger(label: "AXorcist.PathNavigationUtilities") + +// MARK: - Application Element Utilities + +@MainActor +public func getApplicationElement(for bundleIdentifier: String) -> Element? { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/AppEl: Attempting to get application element for bundle identifier '\(bundleIdentifier)'.")) + + guard let runningApp = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == bundleIdentifier + }) else { + GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/AppEl: Could not find running application with bundle identifier '\(bundleIdentifier)'.")) + return nil + } + let pid = runningApp.processIdentifier + let appElement = Element(AXUIElementCreateApplication(pid)) + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/AppEl: Obtained application element for '\(bundleIdentifier)' (PID: \(pid)): [\(appElement.briefDescription(option: ValueFormatOption.smart))]")) + return appElement +} + +@MainActor +public func getApplicationElement(for processId: pid_t) -> Element? { + let appElement = Element(AXUIElementCreateApplication(processId)) + let bundleIdMessagePart: String + if let runningApp = NSRunningApplication(processIdentifier: processId), let bId = runningApp.bundleIdentifier { + bundleIdMessagePart = " (\(bId))" + } else { + bundleIdMessagePart = "" + } + GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/AppEl: Obtained application element for PID \(processId)\(bundleIdMessagePart): [\(appElement.briefDescription(option: ValueFormatOption.smart))]")) + return appElement +} + +// MARK: - Element from Path (High-Level) + +@MainActor +public func getElement( + appIdentifier: String, + pathHint: [Any], + maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch +) -> Element? { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Attempting to get element for app '\(appIdentifier)' with path hint (count: \(pathHint.count)).")) + + let startElement: Element? + if let pid = pid_t(appIdentifier) { + startElement = getApplicationElement(for: pid) + } else { + startElement = getApplicationElement(for: appIdentifier) + } + + guard let rootElement = startElement else { + GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/GetEl: Could not get root application element for '\(appIdentifier)'.")) + return nil + } + + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Root element for '\(appIdentifier)' is [\(rootElement.briefDescription(option: ValueFormatOption.smart))]. Processing path hint.")) + + if let stringPathHint = pathHint as? [String] { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Interpreting path hint as [String]. Count: \(stringPathHint.count). Hint: \(stringPathHint.joined(separator: " -> "))")) + return navigateToElement(from: rootElement, pathHint: stringPathHint, maxDepth: maxDepth) + } else if let jsonPathHint = pathHint as? [JSONPathHintComponent] { + GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Interpreting path hint as [JSONPathHintComponent]. Count: \(jsonPathHint.count). Hint: \(jsonPathHint.map { $0.descriptionForLog() }.joined(separator: " -> "))")) + let initialLogSegment = rootElement.role() == AXRoleNames.kAXApplicationRole ? "Application" : rootElement.briefDescription(option: ValueFormatOption.smart) + return navigateToElementByJSONPathHint(from: rootElement, jsonPathHint: jsonPathHint, overallMaxDepth: maxDepth, initialPathSegmentForLog: initialLogSegment) + } else { + GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "PathNav/GetEl: Path hint type is not [String] or [JSONPathHintComponent]. Hint: \(pathHint). Cannot navigate.")) + return nil + } +} + +// MARK: - Path-based Search + +@MainActor +func findDescendantAtPath( + currentRoot: Element, + pathComponents: [PathStep], + maxDepth: Int, + debugSearch: Bool +) -> Element? { + var currentElement = currentRoot + logger.debug("PathNav/findDescendantAtPath: Starting path navigation. Initial root: \(currentElement.briefDescription(option: .smart)). Path components: \(pathComponents.count)") + + for (pathComponentIndex, component) in pathComponents.enumerated() { + logger.debug("PathNav/findDescendantAtPath: Processing component. Current: \(currentElement.briefDescription(option: .smart))") + + let searchVisitor = SearchVisitor( + criteria: component.criteria, + matchType: component.matchType ?? .exact, + matchAllCriteria: component.matchAllCriteria ?? true, + stopAtFirstMatch: true, + maxDepth: component.maxDepthForStep ?? 1 + ) + + // Children of the current element are where we search for the next path component + logger.debug("PathNav/findDescendantAtPath: [Component \(pathComponentIndex + 1)] Current element for child search: \(currentElement.briefDescription(option: .smart))") + + guard let childrenToSearch = currentElement.children(strict: false), !childrenToSearch.isEmpty else { + logger.warning("PathNav/findDescendantAtPath: [Component \(pathComponentIndex + 1)] No children found (or list was empty) for \(currentElement.briefDescription(option: .smart)). Path navigation cannot proceed further down this branch.") + return nil + } + logger.debug("PathNav/findDescendantAtPath: [Component \(pathComponentIndex + 1)] Found \(childrenToSearch.count) children to search.") + + var foundMatchForThisComponent: Element? = nil + for child in childrenToSearch { + searchVisitor.reset() + traverseAndSearch(element: child, visitor: searchVisitor, currentDepth: 0, maxDepth: component.maxDepthForStep ?? 1) + if let foundUnwrapped = searchVisitor.foundElement { + logger.info("PathNav/findDescendantAtPath: [Component \(pathComponentIndex + 1)] MATCHED component criteria \(component.descriptionForLog()) on child: \(foundUnwrapped.briefDescription(option: ValueFormatOption.smart))") + foundMatchForThisComponent = foundUnwrapped + break + } + } + + if let nextElement = foundMatchForThisComponent { + currentElement = nextElement + logger.debug("PathNav/findDescendantAtPath: [Component \(pathComponentIndex + 1)] Advancing to next element: \(currentElement.briefDescription(option: .smart))") + } else { + logger.warning("PathNav/findDescendantAtPath: [Component \(pathComponentIndex + 1)] FAILED to find match for component criteria: \(component.descriptionForLog()) within children of \(currentElement.briefDescription(option: .smart))") + return nil + } + } + logger.info("PathNav/findDescendantAtPath: Successfully navigated full path. Final element: \(currentElement.briefDescription(option: .smart))") + return currentElement +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/PathNavigator.swift b/Sources/AXorcist/Search/PathNavigator.swift index 6fd9a52..f1f647a 100644 --- a/Sources/AXorcist/Search/PathNavigator.swift +++ b/Sources/AXorcist/Search/PathNavigator.swift @@ -1,510 +1,12 @@ -// PathNavigator.swift - Contains logic for navigating element hierarchies using path hints +// PathNavigator.swift - Main entry point for path navigation functionality +// +// This file has been split into several focused modules: +// - PathNavigationCore.swift - Core navigation functions +// - PathNavigationMatching.swift - Element matching logic +// - PathNavigationJSON.swift - JSON path hint navigation +// - PathNavigationUtilities.swift - Utility functions and high-level APIs +// +// All public functions remain accessible through importing this module. import ApplicationServices -import Foundation -import AppKit // Added for NSRunningApplication -import Logging // Import Logging - -// Note: Assumes Element, PathUtils, Attribute, AXMiscConstants are available. - -// Define logger for this file -private let logger = Logger(label: "AXorcist.PathNavigator") - -// New helper to check if an element matches all given criteria -@MainActor -private func elementMatchesAllCriteria( - _ element: Element, - criteria: [String: String], - forPathComponent pathComponentForLog: String // For logging -) -> Bool { - let elementDescriptionForLog = element.briefDescription(option: ValueFormatOption.smart) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_START: Checking element [\(elementDescriptionForLog)] for component [\(pathComponentForLog)]. Criteria: \(criteria)")) - - if criteria.isEmpty { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Criteria empty for component [\(pathComponentForLog)]. Element [\(elementDescriptionForLog)] considered a match by default.")) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] MATCHED (empty criteria) for component [\(pathComponentForLog)].")) - return true - } - - for (key, expectedValue) in criteria { - let matchTypeForKey: JSONPathHintComponent.MatchType = (key.lowercased() == AXAttributeNames.kAXDOMClassListAttribute.lowercased()) ? .contains : .exact - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC_CRITERION: Checking criterion '\(key): \(expectedValue)' (matchType: \(matchTypeForKey.rawValue)) on element [\(elementDescriptionForLog)] for component [\(pathComponentForLog)].")) - - let criterionDidMatch = matchSingleCriterion(element: element, key: key, expectedValue: expectedValue, matchType: matchTypeForKey, elementDescriptionForLog: elementDescriptionForLog) - let message = "PathNav/EMAC_CRITERION_RESULT: Criterion '\(key): \(expectedValue)' on [\(elementDescriptionForLog)] for [\(pathComponentForLog)]: \(criterionDidMatch ? "MATCHED" : "FAILED")" - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: message)) - - if !criterionDidMatch { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\(elementDescriptionForLog)] FAILED to match criterion '\(key): \(expectedValue)' for component [\(pathComponentForLog)].")) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] FAILED for component [\(pathComponentForLog)].")) - return false - } - } - - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\(elementDescriptionForLog)] successfully MATCHED ALL criteria for component [\(pathComponentForLog)].")) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] MATCHED ALL criteria for component [\(pathComponentForLog)].")) - return true -} - -// Updated navigateToElement to prioritize children -@MainActor -internal func navigateToElement( - from startElement: Element, - pathHint: [String], - maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch -) -> Element? { - var currentElement = startElement - var currentPathSegmentForLog = "" - - for (index, pathComponentString) in pathHint.enumerated() { - currentPathSegmentForLog += (index > 0 ? " -> " : "") + pathComponentString - - if index == 0 && pathComponentString.lowercased() == "application" { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Path component 'application' encountered. Using current element (app root) as context for next component.")) - continue - } - - if index >= maxDepth { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation aborted: Path hint index \(index) reached maxDepth \(maxDepth). Path so far: \(currentPathSegmentForLog)")) - return nil - } - - let criteriaToMatch = PathUtils.parseRichPathComponent(pathComponentString) - guard !criteriaToMatch.isEmpty else { - GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "CRITICAL_NAV_PARSE_FAILURE_MARKER: Empty or unparsable criteria from pathComponentString '\(pathComponentString)'")) - return nil - } - - if let nextElement = processPathComponent( - currentElement: currentElement, - pathComponentString: pathComponentString, - criteriaToMatch: criteriaToMatch, - currentPathSegmentForLog: currentPathSegmentForLog - ) { - currentElement = nextElement - } else { - return nil - } - } - - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation successful. Final element: \(currentElement.briefDescription(option: ValueFormatOption.smart))")) - return currentElement -} - -@MainActor -private func processPathComponent( - currentElement: Element, - pathComponentString: String, - criteriaToMatch: [String: String], - currentPathSegmentForLog: String -) -> Element? { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC_DIRECT_LOG: Entered for \(pathComponentString)")) - - var stepCounter = 0 - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). Before briefDesc.")) - stepCounter += 1 - let briefDesc = currentElement.briefDescription(option: ValueFormatOption.smart) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). Before logPathComponentProcessing. BriefDesc: \(briefDesc)")) - stepCounter += 1 - logPathComponentProcessing(pathComponentString: pathComponentString, briefDesc: briefDesc) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). After logPathComponentProcessing. Before PRE-CALL FMIC.")) - stepCounter += 1 - - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: PRE-CALL FMIC")) - - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). After PRE-CALL FMIC. Before findMatchingChild call.")) - stepCounter += 1 - - if let matchedChild = findMatchingChild( - currentElement: currentElement, - criteriaToMatch: criteriaToMatch, - pathComponentForLog: pathComponentString - ) { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). findMatchingChild returned non-nil.")) - return matchedChild - } - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). findMatchingChild returned nil. Before elementMatchesAllCriteria.")) - stepCounter += 1 - - if elementMatchesAllCriteria(currentElement, criteria: criteriaToMatch, forPathComponent: pathComponentString) { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Current element \(briefDesc) itself matches component '\(pathComponentString)'. Retaining current element for this step.")) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). elementMatchesAllCriteria on currentElement was true.")) - return currentElement - } - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). elementMatchesAllCriteria on currentElement was false. Before logNoMatchFound.")) - stepCounter += 1 - - logNoMatchFound( - briefDesc: briefDesc, - pathComponentString: pathComponentString, - currentPathSegmentForLog: currentPathSegmentForLog - ) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). After logNoMatchFound. Returning nil.")) - return nil -} - -@MainActor -private func logPathComponentProcessing(pathComponentString: String, briefDesc: String) { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigating: Processing path component '\(pathComponentString)' from current element: \(briefDesc)")) -} - -@MainActor -private func logNoMatchFound( - briefDesc: String, - pathComponentString: String, - currentPathSegmentForLog: String -) { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Neither current element \(briefDesc) nor its children (after all checks) matched criteria for path component '\(pathComponentString)'. Path: \(currentPathSegmentForLog) // CHILD_MATCH_FAILURE_MARKER")) -} - -@MainActor -private func findMatchingChild( - currentElement: Element, - criteriaToMatch: [String: String], - pathComponentForLog: String -) -> Element? { - let parentElementDesc = currentElement.briefDescription(option: ValueFormatOption.smart) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_START: Searching children of [\(parentElementDesc)] for component [\(pathComponentForLog)]. Criteria: \(criteriaToMatch)")) - - guard let children = currentElement.children() else { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Element [\(parentElementDesc)] has no children (returned nil for .children()).")) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: No children for [\(parentElementDesc)]. Returning nil.")) - return nil - } - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Element \(parentElementDesc) has \(children.count) children. Iterating...")) - - if children.isEmpty { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Element \(parentElementDesc) has an empty children array.")) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: Empty children array for [\(parentElementDesc)]. Returning nil.")) - return nil - } - - for (childIndex, child) in children.enumerated() { - let childDesc = child.briefDescription(option: ValueFormatOption.smart) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC_CHILD: [Child \(childIndex + 1)/\(children.count)] Processing child [\(childDesc)] of [\(parentElementDesc)] for component [\(pathComponentForLog)].")) - - let childMatched = elementMatchesAllCriteria(child, criteria: criteriaToMatch, forPathComponent: pathComponentForLog) - let message = "PathNav/FMC_CHILD_RESULT: Child [\(childDesc)] of [\(parentElementDesc)] for [\(pathComponentForLog)]: \(childMatched ? "MATCHED" : "DID NOT MATCH")" - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: message)) - - if childMatched { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Child [\(childDesc)] MATCHED for path component [\(pathComponentForLog)].")) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: Found matching child [\(childDesc)] for [\(parentElementDesc)]. Returning child.")) - return child - } - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Child [\(childDesc)] did NOT match criteria for [\(pathComponentForLog)]. Continuing.")) - } - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: No child of \(parentElementDesc) matched criteria for [\(pathComponentForLog)].")) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: No matching child found for [\(parentElementDesc)]. Returning nil.")) - return nil -} - -@MainActor -private func getChildrenFromElement(_ element: Element) -> [Element]? { - guard let children = element.children() else { - let currentElementDescForLog = element.briefDescription(option: ValueFormatOption.smart) - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\(currentElementDescForLog)] has no children (returned nil for .children()).")) - return nil - } - if children.isEmpty { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\(element.briefDescription(option: ValueFormatOption.smart))] has zero children (returned empty array for .children()).")) - } - return children -} - -// MARK: - Index-based Navigation (If still needed, would need careful review) -// The PathUtils.parseRichPathComponent currently does not produce index-based hints. -// If "@index" style hints are required, parseRichPathComponent and the matching logic -// would need to be extended. For now, focusing on attribute-based matching. - -/* - // Example of how index-based logic might be integrated if parseRichPathComponent supported it - // (e.g., by returning a special key like "@index" in criteriaToMatch) - - // In processPathComponent, after trying findMatchingChild and elementMatchesAllCriteria(currentElement...): - if let indexStr = criteriaToMatch[\"@index\"], let index = Int(indexStr) { - if let children = await getChildrenFromElement(currentElement), index >= 0, index < children.count { // Added await - let indexedChild = children[index] - await axDebugLog(\"Path component \'\\(pathComponentString)\' resolved to child at index \\(index): \\(await indexedChild.briefDescription())\") // Added await - return indexedChild - } else { - await axDebugLog(\"Path component \'\\(pathComponentString)\' (index \\(index)) out of bounds for \\(await currentElement.briefDescription()) with \\(await getChildrenFromElement(currentElement)?.count ?? 0) children.\") // Added await - // logNoMatchFound would have been called if attribute matching failed before this. - // If ONLY index was provided and it failed, this is the failure point. - return nil - } - } -*/ - -// MARK: - Deprecated/Replaced original path navigation helpers - -// The following functions were part of an older path navigation system or single-attribute matching -// and are now replaced by the richer criteria-based matching using elementMatchesAllCriteria. -// They are kept here commented out for reference during transition and can be removed later. - -/* -@MainActor -internal func original_currentElementMatchesPathComponent( // Marked as original - _ element: Element, - attributeName: String, - expectedValue: String -) async -> Bool { // Made async - if attributeName.isEmpty { - await axWarningLog(\"original_currentElementMatchesPathComponent: attributeName is empty.\") // Added await - return false - } - // ... (rest of original function would need similar async/await updates for attribute access and logging) ... -} -*/ - - -// MARK: - JSON PathHint Navigation - -// Helper to convert JSONPathHintComponent.AttributeName to actual AXAttribute string -// This might be better placed in a utility struct/enum for AttributeName if it becomes complex -// For now, a simple switch based on the rawValue of the enum. -// UPDATE: This function is problematic because JSONPathHintComponent.AttributeName does not exist. -// The `attribute` in JSONPathHintComponent is already a String. -// This function might have been intended for an earlier version of JSONPathHintComponent. -// Keeping it commented out for now. If direct attribute string usage in JSONPathHintComponent is correct, this is not needed. -/* -private func jsonPathHintAttrToAXAttribute(_ attrName: JSONPathHintComponent.AttributeName) -> String { - switch attrName { - case .role: return AXAttributeNames.kAXRoleAttribute - case .subrole: return AXAttributeNames.kAXSubroleAttribute - case .identifier: return AXAttributeNames.kAXIdentifierAttribute - case .title: return AXAttributeNames.kAXTitleAttribute - case .value: return AXAttributeNames.kAXValueAttribute - case .description: return AXAttributeNames.kAXDescriptionAttribute - // Add other cases as necessary from JSONPathHintComponent.AttributeName - default: - // Fallback or error for unhandled cases - // For now, using the rawValue, but this implies AttributeName has a rawValue or is a string itself. - // This needs to be aligned with the actual definition of JSONPathHintComponent.AttributeName. - // If attrName is already the string (e.g. "AXRole"), then this function is not needed. - // The error "String has no member rawValue" likely points to this. - // If JSONPathHintComponent.attribute is already a String, this function becomes: - // private func jsonPathHintAttrToAXAttribute(_ attrName: String) -> String { return attrName } - // ... or it's just used directly. - GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "jsonPathHintAttrToAXAttribute: Unhandled or direct-use attribute name '\(attrName)'. Using rawValue if available, otherwise direct string.")) - // Assuming attrName might conform to RawRepresentable if it's an enum - // Or if it's already a string, this part is overly complex. - if let raw = (attrName as? any RawRepresentable)?.rawValue as? String { - return raw - } - return String(describing: attrName) // Fallback, likely incorrect if attrName isn't directly the string. - } -} -*/ - - -// Updated navigateToElementByJSONPathHint to use the new Element API and logging -@MainActor -internal func navigateToElementByJSONPathHint( - from startElement: Element, - jsonPathHint: [JSONPathHintComponent], - overallMaxDepth: Int = AXMiscConstants.defaultMaxDepthSearch, - initialPathSegmentForLog: String = "Root" -) -> Element? { - var currentElement = startElement - var currentPathSegmentForLog = initialPathSegmentForLog - let pathHintCount = jsonPathHint.count - - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_START: From [\(startElement.briefDescription(option: ValueFormatOption.smart))] with hint (count: \(pathHintCount)): \(jsonPathHint.map { $0.descriptionForLog() }.joined(separator: " -> "))")) - - for (index, pathComponent) in jsonPathHint.enumerated() { - let componentDescForLog = pathComponent.descriptionForLog() - currentPathSegmentForLog += (index > 0 ? " -> " : " (Start) -> ") + componentDescForLog - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_COMPONENT [\(index + 1)/\(pathHintCount)]: Processing '\(componentDescForLog)'. Current path: [\(currentPathSegmentForLog)]")) - - let depthForThisStep = pathComponent.depth ?? AXMiscConstants.defaultMaxDepthSearchForHintStep - - if index >= overallMaxDepth { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV: Path hint index \(index) reached overallMaxDepth \(overallMaxDepth). Path so far: \(currentPathSegmentForLog)")) - return nil - } - - let attributeToMatch = pathComponent.attribute - let valueToMatch = pathComponent.value - let matchType = pathComponent.matchType ?? .exact - - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_COMPONENT_DETAILS: Attribute: '\(attributeToMatch)', Value: '\(valueToMatch)', MatchType: '\(matchType.rawValue)', DepthForStep: \(depthForThisStep)")) - - let searchCriteria = [Criterion(attribute: attributeToMatch, value: valueToMatch, matchType: matchType)] - - let foundElement = findDescendantMatchingCriteria( - startElement: currentElement, - criteria: searchCriteria, - maxDepth: depthForThisStep, - stopAtFirstMatch: true, - pathComponentForLog: componentDescForLog - ) - - if let nextElement = foundElement { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_MATCH: Component '\(componentDescForLog)' matched by [\(nextElement.briefDescription(option: ValueFormatOption.smart))]. Updating current element.")) - currentElement = nextElement - } else { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_NO_MATCH: Component '\(componentDescForLog)' did not match any element from [\(currentElement.briefDescription(option: ValueFormatOption.smart))] within depth \(depthForThisStep). Path: \(currentPathSegmentForLog)")) - return nil - } - } - - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/JSON_NAV_SUCCESS: Navigation successful. Final element: [\(currentElement.briefDescription(option: ValueFormatOption.smart))] after path: [\(currentPathSegmentForLog)]")) - return currentElement -} - -@MainActor -private func findDescendantMatchingCriteria( - startElement: Element, - criteria: [Criterion], - maxDepth: Int, - stopAtFirstMatch: Bool, - pathComponentForLog: String -) -> Element? { - - if elementMatchesAllCriteria(element: startElement, criteria: criteria) { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FDMC: Start element [\(startElement.briefDescription(option: ValueFormatOption.smart))] itself matches criteria for path component '\(pathComponentForLog)'.")) - return startElement - } - - if maxDepth <= 0 { - return nil - } - - guard let children = startElement.children() else { - return nil - } - - for child in children { - if let found = findDescendantMatchingCriteria( - startElement: child, - criteria: criteria, - maxDepth: maxDepth - 1, - stopAtFirstMatch: stopAtFirstMatch, - pathComponentForLog: pathComponentForLog - ) { - if stopAtFirstMatch { - return found - } - } - } - return nil -} - -// MARK: - Application Root Element Navigation - -@MainActor -public func getApplicationElement(for bundleIdentifier: String) -> Element? { - guard let runningApp = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first else { - GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/AppEl: No running application found for bundle ID '\(bundleIdentifier)'.")) - return nil - } - let pid = runningApp.processIdentifier - let appElement = Element(AXUIElementCreateApplication(pid)) - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/AppEl: Obtained application element for '\(bundleIdentifier)' (PID: \(pid)): [\(appElement.briefDescription(option: ValueFormatOption.smart))]")) - return appElement -} - -@MainActor -public func getApplicationElement(for processId: pid_t) -> Element? { - let appElement = Element(AXUIElementCreateApplication(processId)) - let bundleIdMessagePart: String - if let runningApp = NSRunningApplication(processIdentifier: processId), let bId = runningApp.bundleIdentifier { - bundleIdMessagePart = " (\(bId))" - } else { - bundleIdMessagePart = "" - } - GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/AppEl: Obtained application element for PID \(processId)\(bundleIdMessagePart): [\(appElement.briefDescription(option: ValueFormatOption.smart))]")) - return appElement -} - -// MARK: - Element from Path (High-Level) - -@MainActor -public func getElement( - appIdentifier: String, - pathHint: [Any], - maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch -) -> Element? { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Attempting to get element for app '\(appIdentifier)' with path hint (count: \(pathHint.count)).")) - - let startElement: Element? - if let pid = pid_t(appIdentifier) { - startElement = getApplicationElement(for: pid) - } else { - startElement = getApplicationElement(for: appIdentifier) - } - - guard let rootElement = startElement else { - GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/GetEl: Could not get root application element for '\(appIdentifier)'.")) - return nil - } - - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Root element for '\(appIdentifier)' is [\(rootElement.briefDescription(option: ValueFormatOption.smart))]. Processing path hint.")) - - if let stringPathHint = pathHint as? [String] { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Interpreting path hint as [String]. Count: \(stringPathHint.count). Hint: \(stringPathHint.joined(separator: " -> "))")) - return navigateToElement(from: rootElement, pathHint: stringPathHint, maxDepth: maxDepth) - } else if let jsonPathHint = pathHint as? [JSONPathHintComponent] { - GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Interpreting path hint as [JSONPathHintComponent]. Count: \(jsonPathHint.count). Hint: \(jsonPathHint.map { $0.descriptionForLog() }.joined(separator: " -> "))")) - let initialLogSegment = rootElement.role() == AXRoleNames.kAXApplicationRole ? "Application" : rootElement.briefDescription(option: ValueFormatOption.smart) - return navigateToElementByJSONPathHint(from: rootElement, jsonPathHint: jsonPathHint, overallMaxDepth: maxDepth, initialPathSegmentForLog: initialLogSegment) - } else { - GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "PathNav/GetEl: Path hint type is not [String] or [JSONPathHintComponent]. Hint: \(pathHint). Cannot navigate.")) - return nil - } -} - -@MainActor -func findDescendantAtPath( - currentRoot: Element, - pathComponents: [PathStep], - maxDepth: Int, - debugSearch: Bool -) -> Element? { - var currentElement = currentRoot - logger.debug("PathNav/findDescendantAtPath: Starting path navigation. Initial root: \\(currentElement.briefDescription(option: .smart)). Path components: \\(pathComponents.count)") - - for (_, component) in pathComponents.enumerated() { - // Log messages will use pathComponents.count if needed, index isn't critical for current logging - logger.debug("PathNav/findDescendantAtPath: Processing component. Current: \\(currentElement.briefDescription(option: .smart))") - - let searchVisitor = SearchVisitor( - criteria: component.criteria, - matchType: component.matchType ?? .exact, - matchAllCriteria: component.matchAllCriteria ?? true, - stopAtFirstMatch: true, - maxDepth: component.maxDepthForStep ?? 1 - ) - - // Children of the current element are where we search for the next path component - logger.debug("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] Current element for child search: \\(currentElement.briefDescription(option: .smart))") - - guard let childrenToSearch = currentElement.children(strict: false), !childrenToSearch.isEmpty else { - logger.warning("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] No children found (or list was empty) for \\(currentElement.briefDescription(option: .smart)). Path navigation cannot proceed further down this branch.") - return nil - } - logger.debug("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] Found \\(childrenToSearch.count) children to search.") - - var foundMatchForThisComponent: Element? = nil - for child in childrenToSearch { - searchVisitor.reset() - traverseAndSearch(element: child, visitor: searchVisitor, currentDepth: 0, maxDepth: component.maxDepthForStep ?? 1) - if let foundUnwrapped = searchVisitor.foundElement { - logger.info("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] MATCHED component criteria \\(component.descriptionForLog()) on child: \\(foundUnwrapped.briefDescription(option: ValueFormatOption.smart))") - foundMatchForThisComponent = foundUnwrapped - break - } - } - - if let nextElement = foundMatchForThisComponent { - currentElement = nextElement - logger.debug("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] Advancing to next element: \\(currentElement.briefDescription(option: .smart))") - } else { - logger.warning("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] FAILED to find match for component criteria: \\(component.descriptionForLog()) within children of \\(currentElement.briefDescription(option: .smart))") - return nil - } - } - logger.info("PathNav/findDescendantAtPath: Successfully navigated full path. Final element: \\(currentElement.briefDescription(option: .smart))") - return currentElement -} +import Foundation \ No newline at end of file diff --git a/Sources/axorc/CommandExecutionFunctions.swift b/Sources/axorc/CommandExecutionFunctions.swift new file mode 100644 index 0000000..9e63b90 --- /dev/null +++ b/Sources/axorc/CommandExecutionFunctions.swift @@ -0,0 +1,67 @@ +// CommandExecutionFunctions.swift - Individual command execution functions + +import AXorcist +import Foundation + +// MARK: - Command Execution Functions (now call AXorcist.runCommand) + +@MainActor +internal func executeQuery(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { + guard let axQueryCommand = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert Query to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for Query") + } + + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axQueryCommand)) + return HandlerResponse(from: axResponse) +} + +@MainActor +internal func executeGetFocusedElement(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { + guard let axGetFocusedCmd = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert GetFocusedElement to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for GetFocusedElement") + } + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axGetFocusedCmd)) + return HandlerResponse(from: axResponse) +} + +@MainActor +internal func executeGetAttributes(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { + guard let axGetAttrsCmd = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert GetAttributes to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for GetAttributes") + } + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axGetAttrsCmd)) + return HandlerResponse(from: axResponse) +} + +@MainActor +internal func executeDescribeElement(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { + guard let axDescribeCmd = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert DescribeElement to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for DescribeElement") + } + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axDescribeCmd)) + return HandlerResponse(from: axResponse) +} + +@MainActor +internal func executeExtractText(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { + guard let axExtractCmd = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert ExtractText to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for ExtractText") + } + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axExtractCmd)) + return HandlerResponse(from: axResponse) +} + +@MainActor +internal func executePerformAction(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { + guard let axPerformCmd = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert PerformAction to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for PerformAction") + } + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axPerformCmd)) + return HandlerResponse(from: axResponse) +} \ No newline at end of file diff --git a/Sources/axorc/CommandExecutor.swift b/Sources/axorc/CommandExecutor.swift new file mode 100644 index 0000000..d633458 --- /dev/null +++ b/Sources/axorc/CommandExecutor.swift @@ -0,0 +1,158 @@ +// CommandExecutor.swift - Main command executor that coordinates command processing + +import AppKit // For NSRunningApplication +import AXorcist +import Foundation + +@MainActor +struct CommandExecutor { + + @MainActor + static func execute( + command: CommandEnvelope, + axorcist: AXorcist, + debugCLI: Bool // This is from the --debug CLI flag + ) -> String { + // The main AXORCCommand.run() now sets the global logging based on --debug. + // CommandExecutor.setupLogging can adjust detail level if command.debugLogging is true. + let previousDetailLevel = setupDetailLevelForCommand(commandDebugLogging: command.debugLogging, cliDebug: debugCLI) + + defer { + // Restore only the detail level if it was changed. + if let prevLevel = previousDetailLevel { + GlobalAXLogger.shared.detailLevel = prevLevel + } + } + + axDebugLog("Executing command: \(command.command) (ID: \(command.commandId)), cmdDebug: \(command.debugLogging), cliDebug: \(debugCLI)") + + let responseString = processCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) + + return responseString + } + + // Simplified to only adjust detail level based on command specific flag, if CLI debug is on. + private static func setupDetailLevelForCommand(commandDebugLogging: Bool, cliDebug: Bool) -> AXLogDetailLevel? { + var previousDetailLevel: AXLogDetailLevel? = nil + if cliDebug { // Only adjust if CLI debugging is already enabled + if commandDebugLogging && GlobalAXLogger.shared.detailLevel != .verbose { + previousDetailLevel = GlobalAXLogger.shared.detailLevel + GlobalAXLogger.shared.detailLevel = .verbose + axDebugLog("[CommandExecutor.setupDetailLevel] Upped detail level to verbose for this command.") + } + } else { + // If CLI debug is not on, command.debugLogging by itself does not turn on logging here. + // AXORCMain is the authority for enabling logging globally via --debug. + // However, if command.debugLogging is true but CLI is not, we might want to enable JUST for this command? + // For now, keeping it simple: CLI --debug is master switch. + } + return previousDetailLevel + } + + @MainActor + private static func processCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { + switch command.command { + case .performAction: + return handlePerformActionCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) + + case .getFocusedElement: + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeGetFocusedElement) + + case .getAttributes: + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeGetAttributes) + + case .query: + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeQuery) + + case .describeElement: + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeDescribeElement) + + case .extractText: + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeExtractText) + + case .collectAll: + axDebugLog("CollectAll called. debugCLI=\(debugCLI). Passing to axorcist.handleCollectAll.") + guard let axCommand = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert CollectAll to AXCommand") + let errorResponse = HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for CollectAll") + return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) + } + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axCommand)) + let handlerResponse: HandlerResponse + if axResponse.status == "success" { + handlerResponse = HandlerResponse(data: axResponse.payload, error: nil) + } else { + handlerResponse = HandlerResponse(data: nil, error: axResponse.error?.message ?? "CollectAll failed") + } + return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: handlerResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) + + case .getElementAtPoint: + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) { cmd, ax in + guard let axCmd = cmd.command.toAXCommand(commandEnvelope: cmd) else { + axErrorLog("Failed to convert GetElementAtPoint to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for GetElementAtPoint") + } + let axResponse = ax.runCommand(AXCommandEnvelope(commandID: cmd.commandId, command: axCmd)) + return HandlerResponse(from: axResponse) + } + + case .setFocusedValue: + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) { cmd, ax in + guard let axCmd = cmd.command.toAXCommand(commandEnvelope: cmd) else { + axErrorLog("Failed to convert SetFocusedValue to AXCommand") + return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for SetFocusedValue") + } + let axResponse = ax.runCommand(AXCommandEnvelope(commandID: cmd.commandId, command: axCmd)) + return HandlerResponse(from: axResponse) + } + + case .ping: + return handlePingCommand(command: command, debugCLI: debugCLI) + + case .batch: + return handleBatchCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) + + case .observe: + return handleObserveCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) + + case .stopObservation: + // Stop all observations through AXObserverCenter + AXObserverCenter.shared.removeAllObservers() + let stopResponse = FinalResponse( + commandId: command.commandId, + commandType: command.command.rawValue, + status: "success", + data: AnyCodable("All observations stopped"), + error: nil, + debugLogs: debugCLI || command.debugLogging ? axGetLogsAsStrings() : nil + ) + return encodeToJson(stopResponse) ?? "{\"error\": \"Encoding stopObservation response failed\", \"commandId\": \"\(command.commandId)\"}" + + case .isProcessTrusted: + let trustedResponse = ProcessTrustedResponse( + commandId: command.commandId, + status: "success", + trusted: AXIsProcessTrusted() + ) + return encodeToJson(trustedResponse) ?? "{\"error\": \"Encoding isProcessTrusted response failed\", \"commandId\": \"\(command.commandId)\"}" + + case .isAXFeatureEnabled: + let axEnabled = AXIsProcessTrustedWithOptions(nil) + let featureEnabledResponse = AXFeatureEnabledResponse( + commandId: command.commandId, + status: "success", + enabled: axEnabled + ) + return encodeToJson(featureEnabledResponse) ?? "{\"error\": \"Encoding isAXFeatureEnabled response failed\", \"commandId\": \"\(command.commandId)\"}" + + case .setNotificationHandler: + return handleNotImplementedCommand(command: command, message: "setNotificationHandler is not implemented in axorc", debugCLI: debugCLI) + + case .removeNotificationHandler: + return handleNotImplementedCommand(command: command, message: "removeNotificationHandler is not implemented in axorc", debugCLI: debugCLI) + + case .getElementDescription: + return handleNotImplementedCommand(command: command, message: "getElementDescription is not implemented in axorc", debugCLI: debugCLI) + } + } +} \ No newline at end of file diff --git a/Sources/axorc/CommandHandlers.swift b/Sources/axorc/CommandHandlers.swift new file mode 100644 index 0000000..80b9493 --- /dev/null +++ b/Sources/axorc/CommandHandlers.swift @@ -0,0 +1,118 @@ +// CommandHandlers.swift - Command-specific handler functions + +import AppKit +import AXorcist +import Foundation + +// MARK: - Command Handlers + +@MainActor +internal func handlePerformActionCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { + guard command.actionName != nil else { + let errorResponse = HandlerResponse(data: nil, error: "performAction requires actionName") + return finalizeAndEncodeResponse( + commandId: command.commandId, + commandType: command.command.rawValue, + handlerResponse: errorResponse, + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging + ) + } + + return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executePerformAction) +} + +@MainActor +internal func handleBatchCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { + guard let batchCmd = command.command.toAXCommand(commandEnvelope: command) else { + let errorResponse = BatchQueryResponse(commandId: command.commandId, status: "error", message: "Failed to create AXCommand for Batch") + return encodeToJson(errorResponse) ?? "{\"error\": \"Encoding batch response failed\", \"commandId\": \"\(command.commandId)\"}" + } + + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: batchCmd)) + + var finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "pending") + var logsForResponse: [String]? = nil + + if axResponse.status == "success" { + if let batchPayload = axResponse.payload?.value as? BatchResponsePayload { + finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "success", data: batchPayload.results, errors: batchPayload.errors, debugLogs: nil) + } else { + finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "error", message: "Batch success but payload was not BatchResponsePayload", debugLogs: nil) + } + } else { + let errorMessage = axResponse.error?.message ?? "Batch operation failed with unknown error." + if let batchPayload = axResponse.payload?.value as? BatchResponsePayload { + finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "error", message: errorMessage, data: batchPayload.results, errors: batchPayload.errors, debugLogs: nil) + } else { + finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "error", message: errorMessage, debugLogs: nil) + } + } + + if debugCLI || command.debugLogging { + logsForResponse = axGetLogsAsStrings() + finalResponseObject.debugLogs = logsForResponse + } + + return encodeToJson(finalResponseObject) ?? "{\"error\": \"Encoding batch response failed\", \"commandId\": \"\(command.commandId)\"}" +} + +internal func handlePingCommand(command: CommandEnvelope, debugCLI: Bool) -> String { + axDebugLog("Ping command received. Responding with pong.") + let pingHandlerResponse = HandlerResponse(data: AnyCodable("pong"), error: nil) + return finalizeAndEncodeResponse( + commandId: command.commandId, + commandType: command.command.rawValue, + handlerResponse: pingHandlerResponse, + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging + ) +} + +internal func handleNotImplementedCommand(command: CommandEnvelope, message: String, debugCLI: Bool) -> String { + let notImplementedResponse = HandlerResponse(data: nil, error: message) + return finalizeAndEncodeResponse( + commandId: command.commandId, + commandType: command.command.rawValue, + handlerResponse: notImplementedResponse, + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging + ) +} + +@MainActor +internal func handleObserveCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { + guard let axObserveCommand = command.command.toAXCommand(commandEnvelope: command) else { + axErrorLog("Failed to convert Observe to AXCommand") + let errorResponse = HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for Observe") + return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) + } + + let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axObserveCommand)) + let handlerResponse = HandlerResponse(from: axResponse) + + return finalizeAndEncodeResponse( + commandId: command.commandId, + commandType: command.command.rawValue, + handlerResponse: handlerResponse, + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging + ) +} + +@MainActor +internal func handleSimpleCommand( + command: CommandEnvelope, + axorcist: AXorcist, + debugCLI: Bool, + executor: (CommandEnvelope, AXorcist) -> HandlerResponse +) -> String { + let handlerResponse = executor(command, axorcist) + return finalizeAndEncodeResponse( + commandId: command.commandId, + commandType: command.command.rawValue, + handlerResponse: handlerResponse, + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging + ) +} \ No newline at end of file diff --git a/Sources/axorc/CommandResponseHelpers.swift b/Sources/axorc/CommandResponseHelpers.swift new file mode 100644 index 0000000..f2efc7b --- /dev/null +++ b/Sources/axorc/CommandResponseHelpers.swift @@ -0,0 +1,95 @@ +// CommandResponseHelpers.swift - Response handling and encoding utilities + +import AXorcist +import Foundation + +// MARK: - Response Types + +struct FinalResponse: Codable { + let commandId: String + let commandType: String + let status: String + let data: AnyCodable? + let error: String? + var debugLogs: [String]? +} + +struct ProcessTrustedResponse: Codable { + let commandId: String + let status: String + let trusted: Bool +} + +struct AXFeatureEnabledResponse: Codable { + let commandId: String + let status: String + let enabled: Bool +} + +// MARK: - Response Helpers + +internal func finalizeAndEncodeResponse( + commandId: String, + commandType: String, + handlerResponse: HandlerResponse, + debugCLI: Bool, + commandDebugLogging: Bool +) -> String { + let responseStatus = handlerResponse.error == nil ? "success" : "error" + + var finalResponseObject = FinalResponse( + commandId: commandId, + commandType: commandType, + status: responseStatus, + data: handlerResponse.data, + error: handlerResponse.error + ) + + if debugCLI || commandDebugLogging { + let logsForResponse = axGetLogsAsStrings() + finalResponseObject.debugLogs = logsForResponse + } + + return encodeToJson(finalResponseObject) ?? "{\"error\": \"JSON encoding failed\", \"commandId\": \"\(commandId)\"}" +} + +internal func encodeToJson(_ object: T) -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + do { + let data = try encoder.encode(object) + return String(data: data, encoding: .utf8) + } catch let encodingError as EncodingError { + axErrorLog("JSON encoding failed with EncodingError: \(encodingError.detailedDescription)") + return nil + } catch { + axErrorLog("JSON encoding failed: \(error.localizedDescription)") + return nil + } +} + +// Extension for EncodingError details +protocol CodingPathProvider { + var codingPath: [CodingKey] { get } +} + +extension EncodingError.Context: CodingPathProvider {} + +extension EncodingError { + var detailedDescription: String { + switch self { + case .invalidValue(let value, let context): + return "InvalidValue: '\(value)' attempting to encode at path '\(context.codingPathString)'. Debug: \(context.debugDescription)" + @unknown default: + return "Unknown encoding error. Localized: \(self.localizedDescription)" + } + } +} + +// Helper for CodingPathProvider to get a string representation +extension CodingPathProvider { + var codingPathString: String { + codingPath.map { $0.stringValue }.joined(separator: ".") + } +} \ No newline at end of file diff --git a/Sources/axorc/CommandTypeExtensions.swift b/Sources/axorc/CommandTypeExtensions.swift new file mode 100644 index 0000000..a4f2f25 --- /dev/null +++ b/Sources/axorc/CommandTypeExtensions.swift @@ -0,0 +1,160 @@ +// CommandTypeExtensions.swift - Extensions for CommandType conversions + +import AXorcist +import Foundation + +// MARK: - CommandType Extension for conversion to AXCommand + +extension CommandType { + func toAXCommand(commandEnvelope: CommandEnvelope) -> AXCommand? { + switch self { + case .query: + let effectiveLocator = commandEnvelope.locator ?? Locator(criteria: []) + return .query(QueryCommand( + appIdentifier: commandEnvelope.application, + locator: Locator( + matchAll: effectiveLocator.matchAll, + criteria: effectiveLocator.criteria, + rootElementPathHint: effectiveLocator.rootElementPathHint, + descendantCriteria: effectiveLocator.descendantCriteria, + requireAction: effectiveLocator.requireAction, + computedNameContains: effectiveLocator.computedNameContains, + debugPathSearch: commandEnvelope.locator?.debugPathSearch + ), + attributesToReturn: commandEnvelope.attributes, + maxDepthForSearch: commandEnvelope.maxDepth ?? 10, + includeChildrenBrief: commandEnvelope.includeChildrenBrief + )) + case .performAction: + guard let actionName = commandEnvelope.actionName else { return nil } + return .performAction(PerformActionCommand( + appIdentifier: commandEnvelope.application, + locator: commandEnvelope.locator ?? Locator(criteria: []), + action: actionName, + value: commandEnvelope.actionValue, + maxDepthForSearch: commandEnvelope.maxDepth ?? 10 + )) + case .getAttributes: + return .getAttributes(GetAttributesCommand( + appIdentifier: commandEnvelope.application, + locator: commandEnvelope.locator ?? Locator(criteria: []), + attributes: commandEnvelope.attributes ?? [], + maxDepthForSearch: commandEnvelope.maxDepth ?? 10 + )) + case .describeElement: + return .describeElement(DescribeElementCommand( + appIdentifier: commandEnvelope.application, + locator: commandEnvelope.locator ?? Locator(criteria: []), + depth: commandEnvelope.maxDepth ?? 3, + includeIgnored: commandEnvelope.includeIgnoredElements ?? false, + maxSearchDepth: commandEnvelope.maxDepth ?? 10 + )) + case .extractText: + return .extractText(ExtractTextCommand( + appIdentifier: commandEnvelope.application, + locator: commandEnvelope.locator ?? Locator(criteria: []), + maxDepthForSearch: commandEnvelope.maxDepth ?? 10, + includeChildren: commandEnvelope.includeChildrenInText ?? false, + maxDepth: commandEnvelope.maxDepth + )) + case .collectAll: + return .collectAll(CollectAllCommand( + appIdentifier: commandEnvelope.application, + attributesToReturn: commandEnvelope.attributes, + maxDepth: commandEnvelope.maxDepth ?? 10, + filterCriteria: commandEnvelope.filterCriteria, + valueFormatOption: ValueFormatOption.smart + )) + case .batch: + guard let batchSubCommands = commandEnvelope.subCommands else { + axErrorLog("toAXCommand: Batch command missing subCommands in CommandEnvelope.") + return nil + } + let axSubCommands = batchSubCommands.compactMap { subCmdEnv -> AXBatchCommand.SubCommandEnvelope? in + guard let axSubCmd = subCmdEnv.command.toAXCommand(commandEnvelope: subCmdEnv) else { + axErrorLog("toAXCommand: Failed to convert subCommand '\(subCmdEnv.commandId)' of type '\(subCmdEnv.command.rawValue)' to AXSubCommand.") + return nil + } + return AXBatchCommand.SubCommandEnvelope(commandID: subCmdEnv.commandId, command: axSubCmd) + } + if axSubCommands.count != batchSubCommands.count { + axErrorLog("toAXCommand: Some subCommands in batch failed to convert. Original: \(batchSubCommands.count), Converted: \(axSubCommands.count)") + } + return .batch(AXBatchCommand(commands: axSubCommands)) + + case .setFocusedValue: + guard let value = commandEnvelope.actionValue?.value as? String else { + axErrorLog("toAXCommand: SetFocusedValue missing string value in actionValue or wrong type.") + return nil + } + return .setFocusedValue(SetFocusedValueCommand( + appIdentifier: commandEnvelope.application, + locator: commandEnvelope.locator ?? Locator(criteria: []), + value: value, + maxDepthForSearch: commandEnvelope.maxDepth ?? 10 + )) + + case .getElementAtPoint: + guard let point = commandEnvelope.point else { + axErrorLog("toAXCommand: GetElementAtPoint missing point.") + return nil + } + return .getElementAtPoint(GetElementAtPointCommand( + point: point, + appIdentifier: commandEnvelope.application, + pid: commandEnvelope.pid, + attributesToReturn: commandEnvelope.attributes, + includeChildrenBrief: commandEnvelope.includeChildrenBrief + )) + + case .getFocusedElement: + return .getFocusedElement(GetFocusedElementCommand( + appIdentifier: commandEnvelope.application, + attributesToReturn: commandEnvelope.attributes, + includeChildrenBrief: commandEnvelope.includeChildrenBrief + )) + + case .observe: + guard let notificationsList = commandEnvelope.notifications, !notificationsList.isEmpty else { + axErrorLog("toAXCommand: Observe missing notifications list.") + return nil + } + guard let firstNotificationName = notificationsList.first, + let axNotification = AXNotification(rawValue: firstNotificationName) else { + axErrorLog("toAXCommand: Invalid or unsupported notification name: \(notificationsList.first ?? "nil") for observe command.") + return nil + } + return .observe(ObserveCommand( + appIdentifier: commandEnvelope.application, + locator: commandEnvelope.locator, + notifications: notificationsList, + includeDetails: true, + watchChildren: commandEnvelope.watchChildren ?? false, + notificationName: axNotification, + includeElementDetails: commandEnvelope.includeElementDetails, + maxDepthForSearch: commandEnvelope.maxDepth ?? 10 + )) + + case .ping: + return nil + + case .stopObservation: + return nil + + case .isProcessTrusted: + return nil + + case .isAXFeatureEnabled: + return nil + + case .setNotificationHandler: + return nil + + case .removeNotificationHandler: + return nil + + case .getElementDescription: + return nil + } + } +} \ No newline at end of file diff --git a/Sources/axorc/Core/CommandExecutor.swift b/Sources/axorc/Core/CommandExecutor.swift deleted file mode 100644 index a259891..0000000 --- a/Sources/axorc/Core/CommandExecutor.swift +++ /dev/null @@ -1,574 +0,0 @@ -// CommandExecutor.swift - Executes AXORC commands - -import AppKit // For NSRunningApplication -import AXorcist -import Foundation -// import AXorcist instead of AXorcistLib - -// TEMPORARY TEST STRUCT - REMOVED -// struct SimpleTestResponse: Codable { -// var message: String -// var logs: [String]? -// } - -@MainActor -struct CommandExecutor { - - @MainActor - static func execute( - command: CommandEnvelope, - axorcist: AXorcist, - debugCLI: Bool // This is from the --debug CLI flag - ) -> String { - // let (initialLoggingEnabled, initialDetailLevel) = setupLogging(for: command) - // The main AXORCCommand.run() now sets the global logging based on --debug. - // CommandExecutor.setupLogging can adjust detail level if command.debugLogging is true. - let previousDetailLevel = setupDetailLevelForCommand(commandDebugLogging: command.debugLogging, cliDebug: debugCLI) - - defer { - // Restore only the detail level if it was changed. - if let prevLevel = previousDetailLevel { - GlobalAXLogger.shared.detailLevel = prevLevel - } - } - - // GlobalAXLogger.shared.updateOperationDetails(commandID: command.commandId, appName: command.application) // Commented out for now - - axDebugLog("Executing command: \(command.command) (ID: \(command.commandId)), cmdDebug: \(command.debugLogging), cliDebug: \(debugCLI)") - - let responseString = processCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) - - // Logs are cleared by AXORCMain after printing, if appropriate. - // GlobalAXLogger.shared.updateOperationDetails(commandID: nil, appName: nil) // Commented out for now - - return responseString - } - - // Simplified to only adjust detail level based on command specific flag, if CLI debug is on. - private static func setupDetailLevelForCommand(commandDebugLogging: Bool, cliDebug: Bool) -> AXLogDetailLevel? { - var previousDetailLevel: AXLogDetailLevel? = nil - if cliDebug { // Only adjust if CLI debugging is already enabled - if commandDebugLogging && GlobalAXLogger.shared.detailLevel != .verbose { - previousDetailLevel = GlobalAXLogger.shared.detailLevel - GlobalAXLogger.shared.detailLevel = .verbose - axDebugLog("[CommandExecutor.setupDetailLevel] Upped detail level to verbose for this command.") - } - } else { - // If CLI debug is not on, command.debugLogging by itself does not turn on logging here. - // AXORCMain is the authority for enabling logging globally via --debug. - // However, if command.debugLogging is true but CLI is not, we might want to enable JUST for this command? - // For now, keeping it simple: CLI --debug is master switch. - } - return previousDetailLevel - } - - @MainActor - private static func processCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { - switch command.command { - case .performAction: - return handlePerformActionCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) - - case .getFocusedElement: - return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeGetFocusedElement) - - case .getAttributes: - return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeGetAttributes) - - case .query: - return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeQuery) - - case .describeElement: - return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeDescribeElement) - - case .extractText: - return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeExtractText) - - case .collectAll: - axDebugLog("CollectAll called. debugCLI=\(debugCLI). Passing to axorcist.handleCollectAll.") - guard let axCommand = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert CollectAll to AXCommand") - let errorResponse = HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for CollectAll") - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - } - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axCommand)) - let handlerResponse: HandlerResponse - if axResponse.status == "success" { - handlerResponse = HandlerResponse(data: axResponse.payload, error: nil) - } else { - handlerResponse = HandlerResponse(data: nil, error: axResponse.error?.message ?? "CollectAll failed") - } - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: handlerResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - - case .batch: - return handleBatchCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) - - case .ping: - return handlePingCommand(command: command, debugCLI: debugCLI) - - case .getElementAtPoint: - guard let axCommand = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert GetElementAtPoint to AXCommand") - let errorResponse = HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for GetElementAtPoint") - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - } - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axCommand)) - let handlerResponse = HandlerResponse(from: axResponse) - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: handlerResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - - case .observe: - return handleObserveCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) - - case .setFocusedValue: - return handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeSetFocusedValue) - - case .stopObservation, .isProcessTrusted, .isAXFeatureEnabled, .setNotificationHandler, .removeNotificationHandler, .getElementDescription: - return handleNotImplementedCommand(command: command, message: "Command '\(command.command.rawValue)' is not yet implemented", debugCLI: debugCLI) - } - } - - @MainActor - private static func handlePerformActionCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { - guard command.actionName != nil else { - let error = "Missing action details for performAction" - axErrorLog(error) - let errorResponse = HandlerResponse(data: nil, error: error) - return finalizeAndEncodeResponse( - commandId: command.commandId, - commandType: command.command.rawValue, - handlerResponse: errorResponse, - debugCLI: debugCLI, - commandDebugLogging: command.debugLogging - ) - } - - guard let axCommand = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert PerformAction to AXCommand") - let errorResponse = HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for PerformAction") - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - } - - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axCommand)) - let handlerResponse = HandlerResponse(from: axResponse) - - return finalizeAndEncodeResponse( - commandId: command.commandId, - commandType: command.command.rawValue, - handlerResponse: handlerResponse, - debugCLI: debugCLI, - commandDebugLogging: command.debugLogging - ) - } - - @MainActor - private static func handleSimpleCommand( - command: CommandEnvelope, - axorcist: AXorcist, - debugCLI: Bool, - executor: (CommandEnvelope, AXorcist) -> HandlerResponse - ) -> String { - let handlerResponse = executor(command, axorcist) - return finalizeAndEncodeResponse( - commandId: command.commandId, - commandType: command.command.rawValue, - handlerResponse: handlerResponse, - debugCLI: debugCLI, - commandDebugLogging: command.debugLogging - ) - } - - @MainActor - private static func handleBatchCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { - axDebugLog("handleBatchCommand called with debugCLI: \(debugCLI).") - - guard command.command == .batch else { - let errorMsg = "Batch command structure is incorrect or not a batch command type." - axErrorLog(errorMsg) - let errorResponse = HandlerResponse(data: nil, error: errorMsg) - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: CommandType.batch.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - } - - guard let axBatchCommand = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert Batch to AXCommand") - let errorResponse = HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for Batch") - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: CommandType.batch.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - } - - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axBatchCommand)) - - var finalResponseObject: BatchQueryResponse - var logsForResponse: [String]? = nil - - if axResponse.status == "success" { - if let batchPayload = axResponse.payload?.value as? BatchResponsePayload { - finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "success", data: batchPayload.results, errors: batchPayload.errors, debugLogs: nil) - } else { - finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "error", message: "Batch success but payload was not BatchResponsePayload", debugLogs: nil) - } - } else { - let errorMessage = axResponse.error?.message ?? "Batch operation failed with unknown error." - if let batchPayload = axResponse.payload?.value as? BatchResponsePayload { - finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "error", message: errorMessage, data: batchPayload.results, errors: batchPayload.errors, debugLogs: nil) - } else { - finalResponseObject = BatchQueryResponse(commandId: command.commandId, status: "error", message: errorMessage, debugLogs: nil) - } - } - - if debugCLI || command.debugLogging { - logsForResponse = axGetLogsAsStrings() - finalResponseObject.debugLogs = logsForResponse - } - - return encodeToJson(finalResponseObject) ?? "{\"error\": \"Encoding batch response failed\", \"commandId\": \"\(command.commandId)\"}" - } - - private static func handlePingCommand(command: CommandEnvelope, debugCLI: Bool) -> String { - axDebugLog("Ping command received. Responding with pong.") - let pingHandlerResponse = HandlerResponse(data: AnyCodable("pong"), error: nil) - return finalizeAndEncodeResponse( - commandId: command.commandId, - commandType: command.command.rawValue, - handlerResponse: pingHandlerResponse, - debugCLI: debugCLI, - commandDebugLogging: command.debugLogging - ) - } - - private static func handleNotImplementedCommand(command: CommandEnvelope, message: String, debugCLI: Bool) -> String { - let notImplementedResponse = HandlerResponse(data: nil, error: message) - return finalizeAndEncodeResponse( - commandId: command.commandId, - commandType: command.command.rawValue, - handlerResponse: notImplementedResponse, - debugCLI: debugCLI, - commandDebugLogging: command.debugLogging - ) - } - - @MainActor - private static func handleObserveCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) -> String { - guard let axObserveCommand = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert Observe to AXCommand") - let errorResponse = HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for Observe") - return finalizeAndEncodeResponse(commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: errorResponse, debugCLI: debugCLI, commandDebugLogging: command.debugLogging) - } - - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axObserveCommand)) - let handlerResponse = HandlerResponse(from: axResponse) - - return finalizeAndEncodeResponse( - commandId: command.commandId, - commandType: command.command.rawValue, - handlerResponse: handlerResponse, - debugCLI: debugCLI, - commandDebugLogging: command.debugLogging - ) - } - - // MARK: - Command Execution Functions (now call AXorcist.runCommand) - - @MainActor - private static func executeQuery(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { - guard let axQueryCommand = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert Query to AXCommand") - return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for Query") - } - - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axQueryCommand)) - return HandlerResponse(from: axResponse) - } - - @MainActor - private static func executeGetFocusedElement(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { - guard let axGetFocusedCmd = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert GetFocusedElement to AXCommand") - return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for GetFocusedElement") - } - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axGetFocusedCmd)) - return HandlerResponse(from: axResponse) - } - - @MainActor - private static func executeGetAttributes(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { - guard let axGetAttrsCmd = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert GetAttributes to AXCommand") - return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for GetAttributes") - } - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axGetAttrsCmd)) - return HandlerResponse(from: axResponse) - } - - @MainActor - private static func executeDescribeElement(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { - guard let axDescribeCmd = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert DescribeElement to AXCommand") - return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for DescribeElement") - } - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axDescribeCmd)) - return HandlerResponse(from: axResponse) - } - - @MainActor - private static func executeExtractText(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { - guard let axExtractCmd = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert ExtractText to AXCommand") - return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for ExtractText") - } - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axExtractCmd)) - return HandlerResponse(from: axResponse) - } - - @MainActor - private static func executeSetFocusedValue(command: CommandEnvelope, axorcist: AXorcist) -> HandlerResponse { - guard let axSetFocusedValueCmd = command.command.toAXCommand(commandEnvelope: command) else { - axErrorLog("Failed to convert SetFocusedValue to AXCommand") - return HandlerResponse(data: nil, error: "Internal error: Failed to create AXCommand for SetFocusedValue") - } - let axResponse = axorcist.runCommand(AXCommandEnvelope(commandID: command.commandId, command: axSetFocusedValueCmd)) - return HandlerResponse(from: axResponse) - } - - // MARK: - Response Finalization - - private static func finalizeAndEncodeResponse( - commandId: String, - commandType: String, - handlerResponse: HandlerResponse, - debugCLI: Bool, - commandDebugLogging: Bool - ) -> String { - let dataForResponse: AnyCodable? - if let axElement = handlerResponse.data?.value as? AXElement { - axDebugLog("finalizeAndEncodeResponse: handlerResponse.data contained AXElement. Converting to AXElementForEncoding.") - dataForResponse = AnyCodable(AXElementForEncoding(from: axElement)) - } else if handlerResponse.data != nil { - axDebugLog("finalizeAndEncodeResponse: handlerResponse.data was AnyCodable but not AXElement. Passing through. Type: \(type(of: handlerResponse.data!.value))") - dataForResponse = handlerResponse.data // Pass through other AnyCodable types directly - } else { - axDebugLog("finalizeAndEncodeResponse: handlerResponse.data was nil.") - dataForResponse = nil - } - - var queryResponse = GenericQueryResponse( - commandId: commandId, - commandType: commandType, - status: handlerResponse.error == nil ? "success" : "error", - data: dataForResponse, // Use the potentially converted data - message: handlerResponse.error - ) - - if debugCLI || commandDebugLogging { - queryResponse.debugLogs = axGetLogsAsStrings() - } else { - queryResponse.debugLogs = nil - } - - let jsonString = encodeToJson(queryResponse) - let fallbackJson = """ - {"commandId": "\(commandId)", "commandType": "\(commandType)", "status": "error", "message": "Encoding response failed"} - """ - return jsonString ?? fallbackJson - } - - private static func encodeToJson(_ value: T) -> String? { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - do { - let data = try encoder.encode(value) - return String(data: data, encoding: .utf8) - } catch { - let errorMsg = "JSON Encoding Error for type \(String(describing: T.self)): \(error.localizedDescription)" - fputs("ERROR: \(errorMsg)\n", stderr) - GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMsg, details: ["error_details": AnyCodable(error.localizedDescription)])) - - if let encodingError = error as? EncodingError { - let detailDesc = encodingError.detailedDescription - fputs("ERROR EncodingError Details: \(detailDesc)\n", stderr) - GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "EncodingError Details: \(detailDesc)")) - } - fflush(stderr) - return nil - } - } -} - -// CommandEnvelope.CommandType to AXCommand conversion -extension CommandType { - func toAXCommand(commandEnvelope: CommandEnvelope) -> AXCommand? { - switch self { - case .query: - let effectiveLocator = commandEnvelope.locator ?? Locator(criteria: []) - return .query(QueryCommand( - appIdentifier: commandEnvelope.application, - locator: Locator( - matchAll: effectiveLocator.matchAll, - criteria: effectiveLocator.criteria, - rootElementPathHint: effectiveLocator.rootElementPathHint, - descendantCriteria: effectiveLocator.descendantCriteria, - requireAction: effectiveLocator.requireAction, - computedNameContains: effectiveLocator.computedNameContains, - debugPathSearch: commandEnvelope.locator?.debugPathSearch - ), - attributesToReturn: commandEnvelope.attributes, - maxDepthForSearch: commandEnvelope.maxDepth ?? 10, - includeChildrenBrief: commandEnvelope.includeChildrenBrief - )) - case .performAction: - guard let actionName = commandEnvelope.actionName else { return nil } - return .performAction(PerformActionCommand( - appIdentifier: commandEnvelope.application, - locator: commandEnvelope.locator ?? Locator(criteria: []), - action: actionName, - value: commandEnvelope.actionValue, - maxDepthForSearch: commandEnvelope.maxDepth ?? 10 - )) - case .getAttributes: - return .getAttributes(GetAttributesCommand( - appIdentifier: commandEnvelope.application, - locator: commandEnvelope.locator ?? Locator(criteria: []), - attributes: commandEnvelope.attributes ?? [], - maxDepthForSearch: commandEnvelope.maxDepth ?? 10 - )) - case .describeElement: - return .describeElement(DescribeElementCommand( - appIdentifier: commandEnvelope.application, - locator: commandEnvelope.locator ?? Locator(criteria: []), - depth: commandEnvelope.maxDepth ?? 3, - includeIgnored: commandEnvelope.includeIgnoredElements ?? false, - maxSearchDepth: commandEnvelope.maxDepth ?? 10 - )) - case .extractText: - return .extractText(ExtractTextCommand( - appIdentifier: commandEnvelope.application, - locator: commandEnvelope.locator ?? Locator(criteria: []), - maxDepthForSearch: commandEnvelope.maxDepth ?? 10, - includeChildren: commandEnvelope.includeChildrenInText ?? false, - maxDepth: commandEnvelope.maxDepth - )) - case .collectAll: - return .collectAll(CollectAllCommand( - appIdentifier: commandEnvelope.application, - attributesToReturn: commandEnvelope.attributes, - maxDepth: commandEnvelope.maxDepth ?? 10, - filterCriteria: commandEnvelope.filterCriteria, - valueFormatOption: ValueFormatOption.smart - )) - case .batch: - guard let batchSubCommands = commandEnvelope.subCommands else { - axErrorLog("toAXCommand: Batch command missing subCommands in CommandEnvelope.") - return nil - } - let axSubCommands = batchSubCommands.compactMap { subCmdEnv -> AXBatchCommand.SubCommandEnvelope? in - guard let axSubCmd = subCmdEnv.command.toAXCommand(commandEnvelope: subCmdEnv) else { - axErrorLog("toAXCommand: Failed to convert subCommand '\(subCmdEnv.commandId)' of type '\(subCmdEnv.command.rawValue)' to AXSubCommand.") - return nil - } - return AXBatchCommand.SubCommandEnvelope(commandID: subCmdEnv.commandId, command: axSubCmd) - } - if axSubCommands.count != batchSubCommands.count { - axErrorLog("toAXCommand: Some subCommands in batch failed to convert. Original: \(batchSubCommands.count), Converted: \(axSubCommands.count)") - } - return .batch(AXBatchCommand(commands: axSubCommands)) - - case .setFocusedValue: - guard let value = commandEnvelope.actionValue?.value as? String else { - axErrorLog("toAXCommand: SetFocusedValue missing string value in actionValue or wrong type.") - return nil - } - return .setFocusedValue(SetFocusedValueCommand( - appIdentifier: commandEnvelope.application, - locator: commandEnvelope.locator ?? Locator(criteria: []), - value: value, - maxDepthForSearch: commandEnvelope.maxDepth ?? 10 - )) - - case .getElementAtPoint: - guard let point = commandEnvelope.point else { - axErrorLog("toAXCommand: GetElementAtPoint missing point.") - return nil - } - return .getElementAtPoint(GetElementAtPointCommand( - point: point, - appIdentifier: commandEnvelope.application, - pid: commandEnvelope.pid, - attributesToReturn: commandEnvelope.attributes, - includeChildrenBrief: commandEnvelope.includeChildrenBrief - )) - - case .getFocusedElement: - return .getFocusedElement(GetFocusedElementCommand( - appIdentifier: commandEnvelope.application, - attributesToReturn: commandEnvelope.attributes, - includeChildrenBrief: commandEnvelope.includeChildrenBrief - )) - - case .observe: - guard let notificationsList = commandEnvelope.notifications, !notificationsList.isEmpty else { - axErrorLog("toAXCommand: Observe missing notifications list.") - return nil - } - guard let firstNotificationName = notificationsList.first, - let axNotification = AXNotification(rawValue: firstNotificationName) else { - axErrorLog("toAXCommand: Invalid or unsupported notification name: \(notificationsList.first ?? "nil") for observe command.") - return nil - } - return .observe(ObserveCommand( - appIdentifier: commandEnvelope.application, - locator: commandEnvelope.locator, - notifications: notificationsList, - includeDetails: true, - watchChildren: commandEnvelope.watchChildren ?? false, - notificationName: axNotification, - includeElementDetails: commandEnvelope.includeElementDetails, - maxDepthForSearch: commandEnvelope.maxDepth ?? 10 - )) - - case .ping: - return nil - - case .stopObservation: - return nil - - case .isProcessTrusted: - return nil - - case .isAXFeatureEnabled: - return nil - - case .setNotificationHandler: - return nil - - case .removeNotificationHandler: - return nil - - case .getElementDescription: - return nil - } - } -} - -// Extension for EncodingError details -protocol CodingPathProvider { - var codingPath: [CodingKey] { get } -} - -extension EncodingError.Context: CodingPathProvider {} -// For DecodingError as well if needed later -// extension DecodingError.Context: CodingPathProvider {} - -extension EncodingError { - var detailedDescription: String { - switch self { - case .invalidValue(let value, let context): - return "InvalidValue: '\(value)' attempting to encode at path '\(context.codingPathString)'. Debug: \(context.debugDescription)" - @unknown default: - return "Unknown encoding error. Localized: \(self.localizedDescription)" - } - } -} - -// Helper for CodingPathProvider to get a string representation -extension CodingPathProvider { - var codingPathString: String { - codingPath.map { $0.stringValue }.joined(separator: ".") - } -}