diff --git a/Package.swift b/Package.swift index dbfd134..1ebeb82 100644 --- a/Package.swift +++ b/Package.swift @@ -20,11 +20,9 @@ let package = Package( .target( name: "AXorcist", dependencies: [], - path: "Sources", - exclude: ["axorc"], - sources: [ - "AXorcist" - ] + path: "Sources/AXorcist", // Be very direct about the source path + exclude: [], // Explicitly no excludes + sources: nil // Explicitly let SPM find all sources in the path ), .executableTarget( name: "axorc", // Executable target name diff --git a/Sources/AXorcist/Core/AccessibilityConstants.swift b/Sources/AXorcist/Core/AccessibilityConstants.swift index 14c2dde..f69b4f3 100644 --- a/Sources/AXorcist/Core/AccessibilityConstants.swift +++ b/Sources/AXorcist/Core/AccessibilityConstants.swift @@ -345,6 +345,7 @@ public enum AXMiscConstants { public static let defaultMaxDepthSearch = 10 public static let defaultMaxDepthPathResolution = 10 public static let defaultMaxDepthDescribe = 3 + public static let defaultMaxElementsToCollect = 1000 // New constant for element collection limit public static let defaultTimeoutPerElementCollectAll: TimeInterval = 2.0 // seconds // String Constants (for default/fallback values) diff --git a/Sources/AXorcist/Core/CommandModels.swift b/Sources/AXorcist/Core/CommandModels.swift index 166c293..6614162 100644 --- a/Sources/AXorcist/Core/CommandModels.swift +++ b/Sources/AXorcist/Core/CommandModels.swift @@ -10,7 +10,7 @@ public struct CommandEnvelope: Codable { public let application: String? public let attributes: [String]? public let payload: [String: String]? // For ping compatibility - public let debugLogging: Bool? + public let debugLogging: Bool public let locator: Locator? // Locator from this file public let pathHint: [String]? public let maxElements: Int? @@ -27,6 +27,9 @@ public struct CommandEnvelope: Codable { public let includeElementDetails: [String]? public let watchChildren: Bool? + // New field for collectAll filtering + public let filterCriteria: [String: String]? + enum CodingKeys: String, CodingKey { case commandId case command @@ -48,6 +51,8 @@ public struct CommandEnvelope: Codable { case notifications case includeElementDetails case watchChildren + // CodingKey for new field + case filterCriteria } // Added a public initializer for convenience, matching fields. @@ -56,7 +61,7 @@ public struct CommandEnvelope: Codable { application: String? = nil, attributes: [String]? = nil, payload: [String: String]? = nil, - debugLogging: Bool? = nil, + debugLogging: Bool = false, locator: Locator? = nil, pathHint: [String]? = nil, maxElements: Int? = nil, @@ -70,7 +75,9 @@ public struct CommandEnvelope: Codable { // Init parameters for observe notifications: [String]? = nil, includeElementDetails: [String]? = nil, - watchChildren: Bool? = nil + watchChildren: Bool? = nil, + // Init parameter for new field + filterCriteria: [String: String]? = nil ) { self.commandId = commandId self.command = command @@ -92,6 +99,8 @@ public struct CommandEnvelope: Codable { self.notifications = notifications self.includeElementDetails = includeElementDetails self.watchChildren = watchChildren + // Assignment for new field + self.filterCriteria = filterCriteria } } @@ -100,6 +109,7 @@ public struct Locator: Codable { public var matchAll: Bool? public var criteria: [String: String] public var rootElementPathHint: [String]? + public var descendantCriteria: [String: String]? public var requireAction: String? public var computedNameContains: String? @@ -107,6 +117,7 @@ public struct Locator: Codable { case matchAll case criteria case rootElementPathHint + case descendantCriteria case requireAction case computedNameContains } @@ -115,12 +126,14 @@ public struct Locator: Codable { matchAll: Bool? = nil, criteria: [String: String] = [:], rootElementPathHint: [String]? = nil, + descendantCriteria: [String: String]? = nil, requireAction: String? = nil, computedNameContains: String? = nil ) { self.matchAll = matchAll self.criteria = criteria self.rootElementPathHint = rootElementPathHint + self.descendantCriteria = descendantCriteria self.requireAction = requireAction self.computedNameContains = computedNameContains } diff --git a/Sources/AXorcist/Core/Element+Hierarchy.swift b/Sources/AXorcist/Core/Element+Hierarchy.swift index e4de59a..d3cd935 100644 --- a/Sources/AXorcist/Core/Element+Hierarchy.swift +++ b/Sources/AXorcist/Core/Element+Hierarchy.swift @@ -13,16 +13,16 @@ extension Element { var childCollector = ChildCollector() // ChildCollector will use GlobalAXLogger internally - print("[PRINT Element.children] Before collectDirectChildren for: \(self.briefDescription(option: .default))") + // print("[PRINT Element.children] Before collectDirectChildren for: \(self.briefDescription(option: .default))") collectDirectChildren(collector: &childCollector) - print("[PRINT Element.children] After collectDirectChildren, collector has: \(childCollector.collectedChildrenCount()) unique children.") + // print("[PRINT Element.children] After collectDirectChildren, collector has: \(childCollector.collectedChildrenCount()) unique children.") if !strict { // Only collect alternatives if not strict collectAlternativeChildren(collector: &childCollector) collectApplicationWindows(collector: &childCollector) } - print("[PRINT Element.children] Before finalizeResults, collector has: \(childCollector.collectedChildrenCount()) unique children.") + // print("[PRINT Element.children] Before finalizeResults, collector has: \(childCollector.collectedChildrenCount()) unique children.") let result = childCollector.finalizeResults() axDebugLog("Final children count: \(result?.count ?? 0)") return result diff --git a/Sources/AXorcist/Core/ModelEnums.swift b/Sources/AXorcist/Core/ModelEnums.swift index 929e9d0..c200644 100644 --- a/Sources/AXorcist/Core/ModelEnums.swift +++ b/Sources/AXorcist/Core/ModelEnums.swift @@ -23,5 +23,6 @@ public enum CommandType: String, Codable { case ping case getElementAtPoint case observe + case setFocusedValue // New: sets a value on the currently focused element // Add future commands here, ensuring case matches JSON or provide explicit raw value } diff --git a/Sources/AXorcist/Core/ResponseModels.swift b/Sources/AXorcist/Core/ResponseModels.swift index 8445bf2..e056854 100644 --- a/Sources/AXorcist/Core/ResponseModels.swift +++ b/Sources/AXorcist/Core/ResponseModels.swift @@ -266,7 +266,7 @@ public struct CollectAllOutput: Codable { public let commandId: String public let success: Bool public let command: String - public let collectedElements: [AXElement] + public let collectedElements: [AXElementData] public let appBundleId: String? public let debugLogs: [String]? public let errorMessage: String? diff --git a/Sources/AXorcist/Handlers/AXorcist+ActionHandlers.swift b/Sources/AXorcist/Handlers/AXorcist+ActionHandlers.swift index 759652f..536c101 100644 --- a/Sources/AXorcist/Handlers/AXorcist+ActionHandlers.swift +++ b/Sources/AXorcist/Handlers/AXorcist+ActionHandlers.swift @@ -101,66 +101,61 @@ extension AXorcist { locator: Locator, actionName: String, actionValue: ActionValueCodable? = nil, - pathHint: [PathHintComponent]? = nil, maxDepth: Int? = nil ) async -> HandlerResponse { let logMessage2 = "handlePerformAction: App=\(application ?? AXMiscConstants.focusedApplicationKey), Locator=\(locator), Action=\(actionName), Value=\(String(describing: actionValue))" axInfoLog(logMessage2) - // Determine search depth let searchMaxDepth = maxDepth ?? AXMiscConstants.defaultMaxDepthSearch - // Call the global findTargetElement which returns Result let findResult = await findTargetElement( for: application, locator: locator, - pathHint: pathHint?.compactMap { $0.originalSegment }, // Use .originalSegment maxDepthForSearch: searchMaxDepth ) - let targetElement: Element - // appElement is not directly returned by the new findTargetElement, handle if necessary - // For now, we primarily need the targetElement or error. + if let targetElement = findResult.element { + axDebugLog("handlePerformAction: Element found: \(targetElement.briefDescription())") + // Proceed with targetElement + let axStatus: AXError + var actionErrorString: String? - switch findResult { - case .success(let foundEl): - targetElement = foundEl - case .failure(let errorInfo): - let errorMessage = "handlePerformAction: Error finding element: \(errorInfo.message)" + let standardActions = [ + AXActionNames.kAXIncrementAction, + AXActionNames.kAXDecrementAction, + AXActionNames.kAXConfirmAction, + AXActionNames.kAXCancelAction, + AXActionNames.kAXShowMenuAction, + AXActionNames.kAXPickAction, + AXActionNames.kAXPressAction, + AXActionNames.kAXRaiseAction + ] + + if standardActions.contains(actionName) { + axStatus = executeStandardAccessibilityAction(actionName as CFString, on: targetElement, actionNameForLog: actionName) + } else { + let setResult = executeSetAttributeValueAction(attributeName: actionName, value: actionValue, on: targetElement) + axStatus = setResult.axStatus + actionErrorString = setResult.errorMessage + } + + if axStatus == .success { + axDebugLog("Action '\(actionName)' performed successfully on \(targetElement.briefDescription()).") + return HandlerResponse(data: AnyCodable(PerformResponse(commandId: "", success: true))) + } else { + let finalErrorMessage = actionErrorString ?? "Action '\(actionName)' failed on \(targetElement.briefDescription()) with status: \(axErrorToString(axStatus))" + axErrorLog(finalErrorMessage) + return HandlerResponse(error: finalErrorMessage) + } + } else if let errorMsg = findResult.error { + let errorMessage = "handlePerformAction: Error finding element: \(errorMsg)" axErrorLog(errorMessage) - return HandlerResponse(error: "Error finding element: \(errorInfo.message)") - } - - let axStatus: AXError - var actionErrorString: String? // To capture specific error from set attribute - - let standardActions = [ - AXActionNames.kAXIncrementAction, - AXActionNames.kAXDecrementAction, - AXActionNames.kAXConfirmAction, - AXActionNames.kAXCancelAction, - AXActionNames.kAXShowMenuAction, - AXActionNames.kAXPickAction, - AXActionNames.kAXPressAction, - AXActionNames.kAXRaiseAction - ] - - if standardActions.contains(actionName) { - axStatus = executeStandardAccessibilityAction(actionName as CFString, on: targetElement, actionNameForLog: actionName) + return HandlerResponse(error: errorMessage) } else { - let setResult = executeSetAttributeValueAction(attributeName: actionName, value: actionValue, on: targetElement) - axStatus = setResult.axStatus - actionErrorString = setResult.errorMessage - } - - if axStatus == .success { - axDebugLog("Action '\(actionName)' performed successfully on \(targetElement.briefDescription()).") - // Assuming PerformResponse is a valid Codable struct for the data part - return HandlerResponse(data: AnyCodable(PerformResponse(commandId: "", success: true))) - } else { - let finalErrorMessage = actionErrorString ?? "Action '\(actionName)' failed on \(targetElement.briefDescription()) with status: \(axErrorToString(axStatus))" - axErrorLog(finalErrorMessage) - return HandlerResponse(error: finalErrorMessage) + // Should not happen if findTargetElement always returns either element or error + let errorMessage = "handlePerformAction: Unknown error finding element." + axErrorLog(errorMessage) + return HandlerResponse(error: errorMessage) } } @@ -168,62 +163,55 @@ extension AXorcist { public func handleExtractText( for application: String?, locator: Locator, - pathHint: [PathHintComponent]? = nil, maxDepth: Int? = nil ) async -> HandlerResponse { let logMessage3 = "handleExtractText: App=\(application ?? AXMiscConstants.focusedApplicationKey), Locator=\(locator)" axInfoLog(logMessage3) - // Determine search depth let searchMaxDepth = maxDepth ?? AXMiscConstants.defaultMaxDepthSearch - // Call the global findTargetElement let findResult = await findTargetElement( for: application, locator: locator, - pathHint: pathHint?.compactMap { $0.originalSegment }, // Use .originalSegment maxDepthForSearch: searchMaxDepth ) - let targetElement: Element - // We might need appElement for path generation later, let's try to get it let appElementInstance = applicationElement(for: application ?? AXMiscConstants.focusedApplicationKey) - switch findResult { - case .success(let foundEl): - targetElement = foundEl - case .failure(let errorInfo): - let errorMessage = "handleExtractText: Error finding element: \(errorInfo.message)" + if let targetElement = findResult.element { + axDebugLog("handleExtractText: Element found: \(targetElement.briefDescription())") + // Proceed with targetElement + guard appElementInstance != nil else { + let appNameToLog = application ?? AXMiscConstants.focusedApplicationKey + let errorMsg = "Could not get application element for path generation in handleExtractText for appKey: \(appNameToLog)." + axErrorLog(errorMsg) + return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: nil)), error: errorMsg) + } + + var allTextValues: [String] = [] + if let title: String = targetElement.attribute(.title) { allTextValues.append(title) } + if let desc: String = targetElement.attribute(.description) { allTextValues.append(desc) } + if let valStr: String = targetElement.attribute(Attribute(AXAttributeNames.kAXValueAttribute)) { allTextValues.append(valStr) } + if let selectedText: String = targetElement.attribute(.selectedText) { allTextValues.append(selectedText) } + if let placeholder: String = targetElement.attribute(.placeholderValue) { allTextValues.append(placeholder) } + + let combinedText = allTextValues.joined(separator: " ").lowercased() + + if combinedText.isEmpty { + axDebugLog("No textual content found for element: \(targetElement.briefDescription())") + return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: nil)), error: "No textual content found") + } else { + axDebugLog("Extracted text: '\(combinedText)' from element: \(targetElement.briefDescription())") + return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: combinedText))) + } + } else if let errorMsg = findResult.error { + let errorMessage = "handleExtractText: Error finding element: \(errorMsg)" axErrorLog(errorMessage) - return HandlerResponse(error: "Error finding element: \(errorInfo.message)") - } - - guard appElementInstance != nil else { - let appNameToLog = application ?? AXMiscConstants.focusedApplicationKey - let errorMsg = "Could not get application element for path generation in handleExtractText for appKey: \(appNameToLog)." - axErrorLog(errorMsg) - // Return nil for textContent as part of TextExtractionResponse, not in HandlerResponse.error - return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: nil)), error: errorMsg) - } - - // Text extraction logic - var allTextValues: [String] = [] - if let title: String = targetElement.attribute(.title) { allTextValues.append(title) } - if let desc: String = targetElement.attribute(.description) { allTextValues.append(desc) } - if let valStr: String = targetElement.attribute(Attribute(AXAttributeNames.kAXValueAttribute)) { allTextValues.append(valStr) } - if let selectedText: String = targetElement.attribute(.selectedText) { allTextValues.append(selectedText) } - if let placeholder: String = targetElement.attribute(.placeholderValue) { allTextValues.append(placeholder) } - - let combinedText = allTextValues.joined(separator: " ").lowercased() - - if combinedText.isEmpty { - axDebugLog("No textual content found for element: \(targetElement.briefDescription())") - // Return nil for textContent as part of TextExtractionResponse - return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: nil)), error: "No textual content found") + return HandlerResponse(error: errorMessage) } else { - axDebugLog("Extracted text: '\(combinedText)' from element: \(targetElement.briefDescription())") - // Return extracted text - return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: combinedText))) + let errorMessage = "handleExtractText: Unknown error finding element." + axErrorLog(errorMessage) + return HandlerResponse(error: errorMessage) } } } diff --git a/Sources/AXorcist/Handlers/AXorcist+BatchHandler.swift b/Sources/AXorcist/Handlers/AXorcist+BatchHandler.swift index b3a66ad..38d157d 100644 --- a/Sources/AXorcist/Handlers/AXorcist+BatchHandler.swift +++ b/Sources/AXorcist/Handlers/AXorcist+BatchHandler.swift @@ -10,6 +10,34 @@ import Foundation // MARK: - Batch Processing Handler Extension extension AXorcist { + @MainActor + private func prepareLocator(for subCommandEnvelope: CommandEnvelope, existingLocator: Locator?) -> Locator? { + guard var newLocator = existingLocator else { + // If there's a pathHint on the envelope but no locator, this is problematic. + // For now, if no base locator, we can't effectively use the pathHint from the envelope alone + // unless we construct a new locator, but that might miss criteria. + // This case should ideally be handled by validation upstream or clearer contract. + // If pathHint is the ONLY way to locate, the CommandEnvelope should reflect that. + // For now, just return the locator as is (which might be nil). + if subCommandEnvelope.pathHint != nil && !subCommandEnvelope.pathHint!.isEmpty { + axWarningLog("SubCommand \(subCommandEnvelope.commandId) has a pathHint but no base locator. PathHint will not be used unless locator also has criteria.") + // Optionally, create a locator with only pathHint if that's a valid use case: + // return Locator(criteria: [:], rootElementPathHint: subCommandEnvelope.pathHint) + } + return existingLocator + } + + // If CommandEnvelope.pathHint is provided, and locator.rootElementPathHint is not, + // transfer the pathHint to the locator. + if let topLevelPathHint = subCommandEnvelope.pathHint, + !topLevelPathHint.isEmpty, + newLocator.rootElementPathHint == nil || newLocator.rootElementPathHint!.isEmpty { + axDebugLog("AXorcist+BatchHandler: Populating locator.rootElementPathHint from CommandEnvelope.pathHint for sub-command \(subCommandEnvelope.commandId).") + newLocator.rootElementPathHint = topLevelPathHint + } + return newLocator + } + @MainActor public func handleBatchCommands( batchCommandID: String, // The ID of the overall batch command @@ -56,6 +84,10 @@ extension AXorcist { case .performAction: return await processPerformActionCommand(subCommandEnvelope, subCmdID: subCmdID) + case .setFocusedValue: + axWarningLog("Command 'setFocusedValue' found in batch. Current batch handler does not specifically process it. Returning as unsupported for now.") + return processUnsupportedCommand(subCommandEnvelope, subCmdID: subCmdID) + case .extractText: return await processExtractTextCommand(subCommandEnvelope, subCmdID: subCmdID) @@ -83,16 +115,17 @@ extension AXorcist { @MainActor private func processGetAttributesCommand(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse { - guard let locator = subCommandEnvelope.locator else { + guard let originalLocator = subCommandEnvelope.locator else { let errorMsg = "Locator missing for getAttributes in batch (sub-command ID: \(subCmdID))" axErrorLog(errorMsg) return HandlerResponse(error: errorMsg) } + let finalLocator = prepareLocator(for: subCommandEnvelope, existingLocator: originalLocator) + return await self.handleGetAttributes( for: subCommandEnvelope.application, - locator: locator, + locator: finalLocator!, // Safe to force unwrap as we checked originalLocator requestedAttributes: subCommandEnvelope.attributes, - pathHint: subCommandEnvelope.pathHint, maxDepth: subCommandEnvelope.maxElements, // maxElements often used as maxDepth for search in handlers outputFormat: subCommandEnvelope.outputFormat ) @@ -100,15 +133,16 @@ extension AXorcist { @MainActor private func processQueryCommand(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse { - guard let locator = subCommandEnvelope.locator else { + guard let originalLocator = subCommandEnvelope.locator else { let errorMsg = "Locator missing for query in batch (sub-command ID: \(subCmdID))" axErrorLog(errorMsg) return HandlerResponse(error: errorMsg) } + let finalLocator = prepareLocator(for: subCommandEnvelope, existingLocator: originalLocator) + return await self.handleQuery( for: subCommandEnvelope.application, - locator: locator, - pathHint: subCommandEnvelope.pathHint, + locator: finalLocator!, // Safe to force unwrap maxDepth: subCommandEnvelope.maxElements, requestedAttributes: subCommandEnvelope.attributes, outputFormat: subCommandEnvelope.outputFormat @@ -117,15 +151,16 @@ extension AXorcist { @MainActor private func processDescribeElementCommand(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse { - guard let locator = subCommandEnvelope.locator else { + guard let originalLocator = subCommandEnvelope.locator else { let errorMsg = "Locator missing for describeElement in batch (sub-command ID: \(subCmdID))" axErrorLog(errorMsg) return HandlerResponse(error: errorMsg) } + let finalLocator = prepareLocator(for: subCommandEnvelope, existingLocator: originalLocator) + return await self.handleDescribeElement( for: subCommandEnvelope.application, - locator: locator, - pathHint: subCommandEnvelope.pathHint, + locator: finalLocator!, // Safe to force unwrap maxDepth: subCommandEnvelope.maxDepth, // Use maxDepth for describeElement requestedAttributes: subCommandEnvelope.attributes, outputFormat: subCommandEnvelope.outputFormat @@ -134,7 +169,7 @@ extension AXorcist { @MainActor private func processPerformActionCommand(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse { - guard let locator = subCommandEnvelope.locator else { + guard let originalLocator = subCommandEnvelope.locator else { let errorMsg = "Locator missing for performAction in batch (sub-command ID: \(subCmdID))" axErrorLog(errorMsg) return HandlerResponse(error: errorMsg) @@ -144,29 +179,29 @@ extension AXorcist { axErrorLog(errorMsg) return HandlerResponse(error: errorMsg) } - let pathHintComponents = subCommandEnvelope.pathHint?.compactMap { PathHintComponent(pathSegment: $0) } + let finalLocator = prepareLocator(for: subCommandEnvelope, existingLocator: originalLocator) + return await self.handlePerformAction( for: subCommandEnvelope.application, - locator: locator, // Safely unwrapped above + locator: finalLocator!, // Safe to force unwrap actionName: actionName, actionValue: subCommandEnvelope.actionValue, - pathHint: pathHintComponents, maxDepth: subCommandEnvelope.maxElements // maxElements often used as maxDepth for search in handlers ) } @MainActor private func processExtractTextCommand(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse { - guard let locator = subCommandEnvelope.locator else { + guard let originalLocator = subCommandEnvelope.locator else { let errorMsg = "Locator missing for extractText in batch (sub-command ID: \(subCmdID))" axErrorLog(errorMsg) return HandlerResponse(error: errorMsg) } - let pathHintComponents = subCommandEnvelope.pathHint?.compactMap { PathHintComponent(pathSegment: $0) } + let finalLocator = prepareLocator(for: subCommandEnvelope, existingLocator: originalLocator) + return await self.handleExtractText( for: subCommandEnvelope.application, - locator: locator, // Safely unwrapped above - pathHint: pathHintComponents, + locator: finalLocator!, // Safe to force unwrap maxDepth: subCommandEnvelope.maxElements // maxElements often used as maxDepth for search in handlers ) } diff --git a/Sources/AXorcist/Handlers/AXorcist+CollectAllHandler.swift b/Sources/AXorcist/Handlers/AXorcist+CollectAllHandler.swift index 21d1ec0..4b80a87 100644 --- a/Sources/AXorcist/Handlers/AXorcist+CollectAllHandler.swift +++ b/Sources/AXorcist/Handlers/AXorcist+CollectAllHandler.swift @@ -56,10 +56,12 @@ extension AXorcist { return String(data: jsonData, encoding: .utf8) ?? "{\"error\":\"Failed to encode CollectAllOutput to string (fallback)\"}" } catch { axErrorLog("Exception encoding CollectAllOutput: \(error.localizedDescription)") + let cmdId = output.commandId // Assuming these are direct properties + let cmdType = output.command let errorJson = """ - {"command_id":"\(output.commandId)", \ + {"command_id":"\(cmdId)", \ "success":false, \ - "command":"\(output.command)", \ + "command":"\(cmdType)", \ "error_message":"Catastrophic JSON encoding failure for CollectAllOutput. Original error logged.", \ "collected_elements":[], \ "debug_logs":["Catastrophic JSON encoding failure as well."]} @@ -72,22 +74,22 @@ extension AXorcist { public func handleCollectAll( for appIdentifierOrNil: String?, locator: Locator?, - pathHint: [String]?, maxDepth: Int?, requestedAttributes: [String]?, outputFormat: OutputFormat?, commandId: String?, - debugCLI: Bool + debugCLI: Bool, + filterCriteria: [String: String]? = nil ) async -> String { let params = CollectAllParameters( appIdentifierOrNil: appIdentifierOrNil, locator: locator, - pathHint: pathHint, maxDepth: maxDepth, requestedAttributes: requestedAttributes, outputFormat: outputFormat, commandId: commandId, - focusedAppKey: AXMiscConstants.focusedApplicationKey + focusedAppKey: AXMiscConstants.focusedApplicationKey, + filterCriteria: filterCriteria ) logCollectAllStart(params) @@ -102,10 +104,9 @@ extension AXorcist { ) } - // Determine start element - let startElementResult = await determineStartElement( + // Determine start element using locator.rootElementPathHint + let startElementResult = await determineStartElementForCollectAll( appElement: appElement, - pathHint: pathHint, locator: locator, params: params ) @@ -114,7 +115,7 @@ extension AXorcist { return await createErrorResponse( commandId: params.effectiveCommandId, appIdentifier: params.appIdentifier, - error: startElementResult.error ?? "Failed to determine start element", + error: startElementResult.error ?? "Failed to determine start element for collectAll", debugCLI: debugCLI ) } @@ -142,17 +143,17 @@ extension AXorcist { let attributesToFetch: [String] let effectiveOutputFormat: OutputFormat let locator: Locator? - let pathHint: [String]? + let filterCriteria: [String: String]? init( appIdentifierOrNil: String?, locator: Locator?, - pathHint: [String]?, maxDepth: Int?, requestedAttributes: [String]?, outputFormat: OutputFormat?, commandId: String?, - focusedAppKey: String + focusedAppKey: String, + filterCriteria: [String: String]? ) { self.effectiveCommandId = commandId ?? "collectAll_internal_id_\(UUID().uuidString.prefix(8))" self.appIdentifier = appIdentifierOrNil ?? focusedAppKey @@ -162,105 +163,70 @@ extension AXorcist { self.attributesToFetch = requestedAttributes ?? AXorcist.defaultAttributesToFetch self.effectiveOutputFormat = outputFormat ?? .smart self.locator = locator - self.pathHint = pathHint + self.filterCriteria = filterCriteria } } @MainActor private func logCollectAllStart(_ params: CollectAllParameters) { let appNameForLog = params.appIdentifier - let locatorDesc = params.locator != nil ? String(describing: params.locator!.criteria) : "nil" - let pathHintDesc = String(describing: params.pathHint) + let locatorCriteriaDesc = params.locator?.criteria.isEmpty == false ? String(describing: params.locator!.criteria) : "nil" + let locatorPathHintDesc = params.locator?.rootElementPathHint?.joined(separator: "->") ?? "nil" let maxDepthDesc = String(describing: params.recursionDepthLimit) axInfoLog( "[AXorcist.handleCollectAll] Starting. App: \(appNameForLog), " + - "Locator: \(locatorDesc), PathHint: \(pathHintDesc), MaxDepth: \(maxDepthDesc)" + "LocatorCriteria: \(locatorCriteriaDesc), LocatorPathHint: \(locatorPathHintDesc), MaxDepth: \(maxDepthDesc)" ) - axDebugLog( "Effective recursionDepthLimit: \(params.recursionDepthLimit), " + - "attributesToFetch: \(params.attributesToFetch.count) items, " + - "effectiveOutputFormat: \(params.effectiveOutputFormat.rawValue)" + "attributesToFetch: \(params.attributesToFetch.count) items, " + + "effectiveOutputFormat: \(params.effectiveOutputFormat.rawValue)" ) - axDebugLog("Using app identifier: \(params.appIdentifier)") } @MainActor - private func determineStartElement( + private func determineStartElementForCollectAll( appElement: Element, - pathHint: [String]?, locator: Locator?, params: CollectAllParameters ) async -> (element: Element?, error: String?) { - var startElement = appElement - var pathNavigated = false - - // Navigate to path hint if provided - if let hint = pathHint, !hint.isEmpty { - let pathHintString = hint.joined(separator: " -> ") - axDebugLog("[CollectAll] Navigating to path hint: \(pathHintString)") - - guard let navigatedElement = navigateToElement( - from: appElement, - pathHint: hint, - maxDepth: AXMiscConstants.defaultMaxDepthSearch - ) else { - return (nil, "Failed to navigate to path: \(pathHintString)") + // If locator.rootElementPathHint is provided, use it to find the start element. + if let pathHintStrings = locator?.rootElementPathHint, !pathHintStrings.isEmpty { + let pathHintComponents = pathHintStrings.compactMap { PathHintComponent(pathSegment: $0) } + + if pathHintComponents.count != pathHintStrings.count { + let errorMsg = "[CollectAll] Invalid path hint components in locator for collectAll." + axWarningLog(errorMsg) + return (nil, errorMsg) + } + + if pathHintComponents.isEmpty { + axDebugLog("[CollectAll] Locator provided with empty or unparsable rootElementPathHint. Starting from app root.") + return (appElement, nil) } - startElement = navigatedElement - pathNavigated = true - axDebugLog("[CollectAll] Path navigation successful. Current startElement: \(startElement.briefDescription())") - } else { - axDebugLog("[CollectAll] No pathHint provided. Current startElement: \(startElement.briefDescription()) (app root)") - } - if !pathNavigated, let loc = locator, !loc.criteria.isEmpty { - axDebugLog("[CollectAll] Path navigation did not occur. Trying locator.criteria from startElement: \(startElement.briefDescription())") - if let locatedElement = findElementByLocator( - startElement: startElement, - locator: loc + let pathHintString = pathHintStrings.joined(separator: " -> ") + axDebugLog("[CollectAll] Navigating for start element using locator.rootElementPathHint: \(pathHintString)") + + if let navigatedElement = navigateToElementByPathHint( + pathHint: pathHintComponents, // Assuming this is already defined, if not, use global one + initialSearchElement: appElement, + pathHintMaxDepth: pathHintComponents.count - 1 ) { - axDebugLog( - "[CollectAll] Locator (criteria-only) found element: \(locatedElement.briefDescription()). " + - "This will be the root for collectAll recursion." - ) - startElement = locatedElement + axDebugLog("[CollectAll] Path navigation successful. Start element for collectAll: \(navigatedElement.briefDescription())") + return (navigatedElement, nil) } else { - let locatorDescription = String(describing: loc.criteria) - let currentStartDesc = startElement.briefDescription() - axWarningLog( - "[CollectAll] Locator (criteria-only) provided but no element found for: \(locatorDescription) from \(currentStartDesc). " + - "CollectAll will proceed from \(currentStartDesc)." - ) + let errorMsg = "[CollectAll] Failed to navigate to start element using locator.rootElementPathHint: \(pathHintString)" + axWarningLog(errorMsg) + return (nil, errorMsg) } - } else if pathNavigated { - axDebugLog("[CollectAll] Path navigation occurred. Using element from path as definitive root: \(startElement.briefDescription()). Locator.criteria (if any) will not be used to further refine this root.") - } else if let loc = locator, loc.criteria.isEmpty { - axDebugLog("[CollectAll] Locator provided with empty criteria and no path hint. Using current startElement: \(startElement.briefDescription()) as root.") + } else { + // No rootElementPathHint in locator, or locator is nil. Start from the application element. + axDebugLog("[CollectAll] No rootElementPathHint in locator or locator is nil. Starting collectAll from app root: \(appElement.briefDescription())") + return (appElement, nil) } - - return (startElement, nil) - } - - @MainActor - private func findElementByLocator( - startElement: Element, - locator: Locator - ) -> Element? { - var treeTraverser = TreeTraverser() - let searchVisitor = SearchVisitor(locator: locator, requireAction: locator.requireAction) - var traversalState = TraversalState( - maxDepth: AXMiscConstants.defaultMaxDepthSearch, - startElement: startElement - ) - - return treeTraverser.traverse( - from: startElement, - visitor: searchVisitor, - state: &traversalState - ) } @MainActor @@ -268,34 +234,29 @@ extension AXorcist { startElement: Element, appElement: Element, params: CollectAllParameters - ) async -> [AXElement] { - var traverser = TreeTraverser() + ) async -> [AXElementData] { + axDebugLog( + "[CollectAll.performCollectionTraversal] Starting traversal from: \(startElement.briefDescription()), " + + "MaxDepth: \(params.recursionDepthLimit)" + ) let visitor = CollectAllVisitor( attributesToFetch: params.attributesToFetch, outputFormat: params.effectiveOutputFormat, - appElement: appElement + appElement: appElement, + valueFormatOption: .default, + filterCriteria: params.filterCriteria ) - - var traversalState = TraversalState( + /* ElementSearch. */collectAll( + appElement: appElement, + locator: params.locator ?? Locator(criteria: [:]), + currentElement: startElement, + depth: 0, maxDepth: params.recursionDepthLimit, - startElement: startElement, - strictChildren: true + maxElements: AXMiscConstants.defaultMaxElementsToCollect, + visitor: visitor ) - - axDebugLog("[Pre-Traverse PCT] Handler: validStartElement is: \(startElement.briefDescription(option: .default)) with strictChildren=true") - _ = traverser.traverse(from: startElement, visitor: visitor, state: &traversalState) - - let collectedElementsData = visitor.collectedElements - let collectedElementsOutput = collectedElementsData.map { data in - AXElement(attributes: data.attributes, path: data.path) - } - - axDebugLog("Traversal complete. Collected \(collectedElementsOutput.count) elements.") - if collectedElementsOutput.isEmpty { - axInfoLog("No elements collected, but traversal itself was successful.") - } - - return collectedElementsOutput + axDebugLog("[CollectAll.performCollectionTraversal] Traversal complete. Collected \(visitor.collectedElements.count) elements.") + return visitor.collectedElements } @MainActor @@ -305,34 +266,34 @@ extension AXorcist { error: String, debugCLI: Bool ) async -> String { - axErrorLog(error) - // Conditionally fetch logs based on debugCLI + axErrorLog("[CollectAll] Error for app \(appIdentifier): \(error)") let logs = debugCLI ? await GlobalAXLogger.shared.getLogsAsStrings(format: .text) : nil - return encode(CollectAllOutput( + let output = CollectAllOutput( commandId: commandId, success: false, command: "collectAll", - collectedElements: [], + collectedElements: [], // Empty for error response appBundleId: appIdentifier, debugLogs: logs, errorMessage: error - )) + ) + return encode(output) } @MainActor private func createSuccessResponse( commandId: String, appIdentifier: String, - collectedElements: [AXElement], + collectedElements collectedElementsData: [AXElementData], debugCLI: Bool ) async -> String { - // Conditionally fetch logs based on debugCLI + axInfoLog("[CollectAll] Successfully collected \(collectedElementsData.count) elements for app \(appIdentifier).") let logs = debugCLI ? await GlobalAXLogger.shared.getLogsAsStrings(format: .text) : nil let output = CollectAllOutput( commandId: commandId, success: true, command: "collectAll", - collectedElements: collectedElements, + collectedElements: collectedElementsData, // Pass the data directly appBundleId: appIdentifier, debugLogs: logs, errorMessage: nil @@ -340,3 +301,39 @@ extension AXorcist { return encode(output) } } + +// Assuming CollectAllOutput is defined something like this: +// struct CollectAllOutput: Codable { +// var commandId: String +// var success: Bool +// var command: String // e.g., "collectAll" +// var errorMessage: String? +// var collectedElements: [AXElementData] +// var appBundleId: String +// var debugLogs: [String]? +// +// enum CodingKeys: String, CodingKey { +// case commandId = "command_id" +// case success +// case command +// case errorMessage = "error_message" // Ensure consistency if CommandEnvelope uses error_message +// case collectedElements = "collected_elements" +// case appBundleId = "app_bundle_id" +// case debugLogs = "debug_logs" +// } +// } + +// Make sure AXElementData is defined, probably in DataModels.swift or similar +// public struct AXElementData: Codable { ... } + +// Ensure navigateToElementByPathHint is accessible. It is private in ElementSearch.swift. +// For this refactor, we'll assume it's made internal or public, or we use findTargetElement. +// The call `AXorcist.collectAll` in `performCollectionTraversal` refers to the global func in ElementSearch.swift +// It needs to be `ElementSearch.collectAll` or just `collectAll` if in the same module and accessible. +// For the edit, I will assume `collectAll` is callable as a static/global function. +// And `navigateToElementByPathHint` is also made accessible for `determineStartElementForCollectAll`. + +// To make `navigateToElementByPathHint` and `collectAll` (from ElementSearch) accessible here, +// they should be marked `internal` or `public` in ElementSearch.swift if AXorcist+CollectAllHandler.swift +// is in a different file but same module, or `public` if different modules. +// For simplicity of this step, I'm writing the logic as if they are callable. diff --git a/Sources/AXorcist/Handlers/AXorcist+QueryHandlers.swift b/Sources/AXorcist/Handlers/AXorcist+QueryHandlers.swift index 09521eb..e701336 100644 --- a/Sources/AXorcist/Handlers/AXorcist+QueryHandlers.swift +++ b/Sources/AXorcist/Handlers/AXorcist+QueryHandlers.swift @@ -6,7 +6,7 @@ import Foundation // GlobalAXLogger is assumed to be available // Define arrow separator constant for joining path hints -private let arrowSeparator = " -> " +// private let arrowSeparator = " -> " // No longer needed here // MARK: - Query & Search Handlers Extension extension AXorcist { @@ -16,61 +16,43 @@ extension AXorcist { @MainActor public func handleQuery( for appIdentifierOrNil: String?, - locator: Locator, - pathHint: [String]?, + locator: Locator, // Only locator is needed + // pathHint: [String]?, // REMOVED maxDepth: Int?, requestedAttributes: [String]?, outputFormat: OutputFormat? ) async -> HandlerResponse { - // REMOVED: SearchVisitor.resetGlobalVisitCount() // Reset before each query-like operation - let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey - axDebugLog("Handling query for app: \(appIdentifier)", - file: #file, - function: #function, - line: #line - ) + axDebugLog("Handling query for app: \(appIdentifier), locator: \(locator)", + file: #file, function: #function, line: #line) - // Get the application element - guard let appElement = applicationElement(for: appIdentifier) else { - axErrorLog("Application not found: \(appIdentifier)", - file: #file, - function: #function, - line: #line - ) - return HandlerResponse( - data: nil, - error: "Application not found: \(appIdentifier)" - ) - } - - // Navigate using path hint if provided - let effectiveElementResult = navigateWithPathHintIfNeeded( - appElement: appElement, - pathHint: pathHint - ) - - guard let effectiveElement = effectiveElementResult.element else { - return HandlerResponse( - data: nil, - error: effectiveElementResult.error ?? "Failed to navigate with path hint" - ) - } - - // Find the target element based on the locator - let foundElementResult = findElementWithLocator( + // findTargetElement will handle app element creation and use locator.rootElementPathHint + let findResult = await findTargetElement( + for: appIdentifier, locator: locator, - effectiveElement: effectiveElement, - appElement: appElement, - maxDepth: maxDepth + // pathHint parameter removed from findTargetElement call + maxDepthForSearch: maxDepth ?? AXMiscConstants.defaultMaxDepthSearch ) - guard let foundElement = foundElementResult.element else { + guard let foundElement = findResult.element else { return HandlerResponse( data: nil, - error: foundElementResult.error ?? "Element not found" + error: findResult.error ?? "Element not found by handleQuery." ) } + + // Need appElement for path generation in buildQueryResponse + guard let appElement = applicationElement(for: appIdentifier) else { + axErrorLog("Application not found for path context: \(appIdentifier)") + // Proceed with foundElement but path might be relative or incomplete + return buildQueryResponse( + element: foundElement, + appElement: nil, // Pass nil for appElement + requestedAttributes: requestedAttributes, + outputFormat: outputFormat + ) + } + // Get attributes and build response return buildQueryResponse( @@ -81,43 +63,19 @@ extension AXorcist { ) } - // Helper: Navigate with path hint if provided + // Helper: Navigate with path hint if provided - REMOVED as findTargetElement handles this via locator + /* @MainActor private func navigateWithPathHintIfNeeded( appElement: Element, pathHint: [String]? ) -> (element: Element?, error: String?) { - var effectiveElement = appElement - - if let pathHint = pathHint, !pathHint.isEmpty { - let pathHintString = pathHint.joined(separator: arrowSeparator) - axDebugLog("Navigating with path_hint: \(pathHintString)", - file: #file, - function: #function, - line: #line - ) - - if let navigatedElement = navigateToElement( - from: effectiveElement, - pathHint: pathHint, - maxDepth: AXMiscConstants.defaultMaxDepthSearch - ) { - effectiveElement = navigatedElement - } else { - let errorMsg = "Element not found via path hint: \(pathHintString)" - axErrorLog(errorMsg, - file: #file, - function: #function, - line: #line - ) - return (nil, errorMsg) - } - } - - return (effectiveElement, nil) + // ... implementation removed ... } + */ - // Helper: Find element with locator + // Helper: Find element with locator - REMOVED as findTargetElement handles this + /* @MainActor private func findElementWithLocator( locator: Locator, @@ -125,113 +83,27 @@ extension AXorcist { appElement: Element, maxDepth: Int? ) -> (element: Element?, error: String?) { - let appSpecifiers = ["application", "bundle_id", "pid", "path"] - let criteriaKeys = locator.criteria.keys - let isAppOnlyLocator = criteriaKeys.allSatisfy { appSpecifiers.contains($0) } && criteriaKeys.count == 1 - - if isAppOnlyLocator { - axDebugLog("Locator is app-only (criteria: \(locator.criteria)). Using appElement directly.", - file: #file, - function: #function, - line: #line - ) - return (effectiveElement, nil) - } - - // Find search start element based on rootElementPathHint - let searchStartResult = findSearchStartElement( - locator: locator, - effectiveElement: effectiveElement, - appElement: appElement - ) - - guard let searchStartElement = searchStartResult.element else { - return (nil, searchStartResult.error) - } - - // Perform the search - let searchResult = self.search( - element: searchStartElement, - locator: locator, - requireAction: locator.requireAction, - depth: 0, - maxDepth: maxDepth ?? AXMiscConstants.defaultMaxDepthSearch - ) - - if searchResult == nil { - axWarningLog("No element matches single query criteria with locator or app-only locator failed to resolve.", - file: #file, - function: #function, - line: #line - ) - return (nil, "No element matches single query criteria with locator or app-only locator failed to resolve.") - } - - return (searchResult, nil) + // ... implementation removed ... } - - // Helper: Find search start element + */ + + // Helper: Find search start element - REMOVED as findTargetElement handles this + /* @MainActor private func findSearchStartElement( locator: Locator, effectiveElement: Element, appElement: Element ) -> (element: Element?, error: String?) { - axDebugLog("Locator contains element-specific criteria or is complex. Proceeding with search.", - file: #file, - function: #function, - line: #line - ) - - var searchStartElement = effectiveElement - - if let rootPathHint = locator.rootElementPathHint, !rootPathHint.isEmpty { - axDebugLog("Locator has rootElementPathHint: \(rootPathHint.joined(separator: " -> ")). Navigating from app element first.", - file: #file, - function: #function, - line: #line - ) - - guard let containerElement = navigateToElement( - from: appElement, - pathHint: rootPathHint, - maxDepth: AXMiscConstants.defaultMaxDepthSearch - ) else { - let errorMsg = "Container for locator not found via rootElementPathHint: \(rootPathHint.joined(separator: " -> "))" - axErrorLog(errorMsg, - file: #file, - function: #function, - line: #line - ) - return (nil, errorMsg) - } - - searchStartElement = containerElement - axDebugLog( - "Searching with locator within container found by root_element_path_hint: " + - "\(searchStartElement.briefDescription(option: .default))", - file: #file, - function: #function, - line: #line - ) - } else { - axDebugLog( - "Searching with locator from element (determined by main path_hint or app root): " + - "\(searchStartElement.briefDescription(option: .default))", - file: #file, - function: #function, - line: #line - ) - } - - return (searchStartElement, nil) + // ... implementation removed ... } + */ - // Helper: Build query response + // Helper: Build query response - made internal to be accessible by other handlers @MainActor - private func buildQueryResponse( + internal func buildQueryResponse( element: Element, - appElement: Element, + appElement: Element?, // Changed to optional requestedAttributes: [String]?, outputFormat: OutputFormat? ) -> HandlerResponse { @@ -243,7 +115,7 @@ extension AXorcist { let axElement = AXElement( attributes: attributes, - path: element.generatePathArray(upTo: appElement) + path: element.generatePathArray(upTo: appElement) // Pass appElement (optional) ) return HandlerResponse( @@ -258,633 +130,199 @@ extension AXorcist { public func handleGetAttributes( for appIdentifierOrNil: String?, locator: Locator, + // pathHint: [String]?, // REMOVED requestedAttributes: [String]?, - pathHint: [String]?, maxDepth: Int?, outputFormat: OutputFormat? ) async -> HandlerResponse { - // REMOVED: SearchVisitor.resetGlobalVisitCount() // Reset for getAttributes - let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey - axDebugLog("Handling get_attributes command for app: \(appIdentifier)", - file: #file, - function: #function, - line: #line - ) + axDebugLog("Handling getAttributes for app: \(appIdentifier), locator: \(locator)", + file: #file, function: #function, line: #line) - let targetElementResult = await self.findTargetElement( + // findTargetElement will handle app element creation and use locator.rootElementPathHint + let findResult = await findTargetElement( for: appIdentifier, locator: locator, - pathHint: pathHint, + // pathHint parameter removed maxDepthForSearch: maxDepth ?? AXMiscConstants.defaultMaxDepthSearch ) - let foundElement: Element - switch targetElementResult { - case .failure(let errorData): - axErrorLog("Failed to find target element in handleGetAttributes: \(errorData.message)", - file: #file, - function: #function, - line: #line + guard let foundElement = findResult.element else { + return HandlerResponse( + data: nil, + error: findResult.error ?? "Element not found by handleGetAttributes." ) - return HandlerResponse(data: nil, error: errorData.message) - case .success(let element): - foundElement = element } - - let attributesDescription = (requestedAttributes ?? ["all"]).joined(separator: ", ") - let logMessage = "handleGetAttributes: Element found: \(foundElement.briefDescription(option: .default))." - axDebugLog(logMessage, details: ["requestedAttributes": attributesDescription], file: #file, function: #function, line: #line) - - let elementToQuery = foundElement + + // Get attributes (without path for this specific handler) let (attributes, _) = getElementAttributes( - element: elementToQuery, - attributes: requestedAttributes ?? [], + element: foundElement, + attributes: requestedAttributes ?? AXorcist.defaultAttributesToFetch, outputFormat: outputFormat ?? .smart ) - // Removed: if outputFormat == .json_string { attributes = encodeAttributesToJSONStringRepresentation(attributes) } - axDebugLog("Successfully fetched attributes for element \(elementToQuery.briefDescription(option: .default)).", - file: #file, - function: #function, - line: #line - ) + + // For getAttributes, the data is often just the attributes dictionary directly. + // Wrapping it in AXElement like query does might be okay, or return attributes directly. + // For consistency with QueryResponse, let's make data be AXElement with only attributes set. + let axElementData = AXElement(attributes: attributes, path: nil) - let axElement = AXElement(attributes: attributes, path: elementToQuery.generatePathArray()) // Assuming generatePathArray without arg is fine or adjust - return HandlerResponse(data: AnyCodable(axElement), error: nil) + + return HandlerResponse(data: AnyCodable(axElementData), error: nil) } + // MARK: - handleDescribeElement + @MainActor public func handleDescribeElement( for appIdentifierOrNil: String?, locator: Locator, - pathHint: [String]?, - maxDepth: Int?, + // pathHint: [String]?, // REMOVED + maxDepth: Int?, // This maxDepth is for the description tree, not necessarily search requestedAttributes: [String]?, outputFormat: OutputFormat? ) async -> HandlerResponse { - // REMOVED: SearchVisitor.resetGlobalVisitCount() // Reset for describeElement - let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey - axDebugLog("Handling describe_element for app: \(appIdentifier)", - file: #file, - function: #function, - line: #line - ) + axDebugLog("Handling describeElement for app: \(appIdentifier), locator: \(locator)", + file: #file, function: #function, line: #line) - let searchMaxDepth = AXMiscConstants.defaultMaxDepthSearch + // Search maxDepth for finding the element itself. + let searchMaxDepth = AXMiscConstants.defaultMaxDepthSearch // Use a sensible default for finding the element - let targetElementResult = await self.findTargetElement( + // findTargetElement will handle app element creation and use locator.rootElementPathHint + let findResult = await findTargetElement( for: appIdentifier, locator: locator, - pathHint: pathHint, + // pathHint parameter removed maxDepthForSearch: searchMaxDepth ) - let elementToDescribe: Element - switch targetElementResult { - case .failure(let errorData): - axErrorLog("Failed to find target element in handleDescribeElement: \(errorData.message)", - file: #file, - function: #function, - line: #line + guard let foundElement = findResult.element else { + return HandlerResponse( + data: nil, + error: findResult.error ?? "Element not found by handleDescribeElement." ) - return HandlerResponse(data: nil, error: errorData.message) - case .success(let element): - elementToDescribe = element + } + + // Need appElement for path generation if it's part of the description + guard let appElement = applicationElement(for: appIdentifier) else { + axErrorLog("Application not found for path context in describeElement: \(appIdentifier)") + // Fallback or error + return HandlerResponse(error: "Application \(appIdentifier) not found for describeElement context.") } - axDebugLog("Element to describe found: \(elementToDescribe.briefDescription(option: .default)). Building tree...", - file: #file, - function: #function, - line: #line - ) + // maxDepth for describe is how deep the description tree should go + let descriptionTreeMaxDepth = maxDepth ?? AXMiscConstants.defaultMaxDepthDescribe - let treeElement = await buildElementTree( - from: elementToDescribe, - depth: 0, - maxDepth: maxDepth ?? AXMiscConstants.defaultMaxDepthDescribe, - requestedAttributes: requestedAttributes ?? AXorcist.defaultAttributesToFetch, - includeActions: true, + let elementTree = describeElementTree( + element: foundElement, + appElement: appElement, // For path context in description + maxDepth: descriptionTreeMaxDepth, + currentDepth: 0, + requestedAttributes: requestedAttributes, outputFormat: outputFormat ?? .smart ) - return HandlerResponse(data: AnyCodable(treeElement), error: nil) + return HandlerResponse(data: AnyCodable(elementTree), error: nil) } - // MARK: - fetchElementTree (Core tree building logic for CollectAll and DescribeElement) + + // MARK: - Helper: Describe Element Tree (Recursive) + // Made internal to be accessible @MainActor - public func fetchElementTree( - for appIdentifierOrNil: String?, - targeting elementRef: AXUIElement?, - locator: Locator?, - pathHint: [String]?, - maxDepth: Int?, - requestedAttributes: [String]?, - includeActions: Bool, - outputFormat: OutputFormat? - ) async -> HandlerResponse { - // REMOVED: SearchVisitor.resetGlobalVisitCount() // Reset for fetchElementTree - - let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey - axDebugLog("Fetching element tree for app: \(appIdentifier)", - file: #file, - function: #function, - line: #line - ) - - var rootElementForTreeBuild: Element? - - if let ref = elementRef { - axDebugLog("Using provided AXUIElement ref as root for tree build.", - file: #file, - function: #function, - line: #line - ) - rootElementForTreeBuild = Element(ref) - } else { - guard let appElement = applicationElement(for: appIdentifier) else { - axErrorLog("Application element not found for \(appIdentifier) in fetchElementTree", - file: #file, - function: #function, - line: #line - ) - return HandlerResponse(data: nil, error: "Application not found: \(appIdentifier)") - } - - if let loc = locator { - let findResult = await findTargetElement(for: appIdentifier, locator: loc, pathHint: pathHint, maxDepthForSearch: AXMiscConstants.defaultMaxDepthSearch) - switch findResult { - case .success(let foundEl): - rootElementForTreeBuild = foundEl - case .failure(let err): - axErrorLog("Failed to find element with locator for tree root: \(err.message)", - file: #file, - function: #function, - line: #line - ) - return HandlerResponse(data: nil, error: "Element for tree root not found with locator: \(err.message)") - } - } else if let hint = pathHint, !hint.isEmpty { - if let navigated = navigateToElement(from: appElement, pathHint: hint, maxDepth: AXMiscConstants.defaultMaxDepthSearch) { - rootElementForTreeBuild = navigated - } else { - let errorMsg = "Element for tree root not found via path hint: \(hint.joined(separator: " -> "))" - axErrorLog(errorMsg, - file: #file, - function: #function, - line: #line - ) - return HandlerResponse(data: nil, error: errorMsg) - } - } else { - rootElementForTreeBuild = appElement // Default to app element if no locator or pathHint for specific root - } - } - - guard let finalRootElement = rootElementForTreeBuild else { - axErrorLog("Could not determine a root element for tree building.", - file: #file, - function: #function, - line: #line - ) - return HandlerResponse(data: nil, error: "Could not determine root element for tree.") - } - - axDebugLog("Final root for tree build: \(finalRootElement.briefDescription(option: .default)). Building tree...", - file: #file, - function: #function, - line: #line - ) - - let treeElement = await buildElementTree( - from: finalRootElement, - depth: 0, - maxDepth: maxDepth ?? AXMiscConstants.defaultMaxDepthDescribe, - requestedAttributes: requestedAttributes ?? AXorcist.defaultAttributesToFetch, - includeActions: includeActions, - outputFormat: outputFormat ?? .smart - ) - - return HandlerResponse(data: AnyCodable(treeElement), error: nil) - } - - // MARK: - Internal Helper: buildElementTree - @MainActor - private func buildElementTree( - from element: Element, - depth: Int, - maxDepth: Int, - requestedAttributes: [String], - includeActions: Bool, - outputFormat: OutputFormat - ) async -> AXElement { - // Removed: SearchVisitor.incrementVisitCount() - axDebugLog("buildElementTree: Visiting \(element.briefDescription(option: .short)), Depth: \(depth)/\(maxDepth)", file: #file, function: #function, line: #line) - - let (currentElementAttributes, _) = getElementFullDescription( - element: element, - valueFormatOption: .default, // Or make configurable - includeActions: includeActions, - includeStoredAttributes: true // Usually true for a full description - ) - - var processedAttributes = currentElementAttributes - // Removed: if outputFormat == .json_string { processedAttributes = encodeAttributesToJSONStringRepresentation(processedAttributes) } - - var childrenAXElements: [AXElement]? // Initialize as nil - - if depth < maxDepth { - // Removed: if SearchVisitor.globalVisitCount > AXMiscConstants.maxTotalElementsVisitLimit logic - - if let childElements = element.children() { // Element.children() returns [Element]?, removed await - childrenAXElements = [] // Initialize array if there are children - for childElement in childElements { - // Removed: if SearchVisitor.globalVisitCount > AXMiscConstants.maxTotalElementsVisitLimit check before recursive call - let childAXElement = await buildElementTree( - from: childElement, - depth: depth + 1, - maxDepth: maxDepth, - requestedAttributes: requestedAttributes, - includeActions: includeActions, - outputFormat: outputFormat - ) - childrenAXElements!.append(childAXElement) - } - } - } else { - if depth >= maxDepth { - axDebugLog("Max depth (\(maxDepth)) reached for element \(element.briefDescription(option: .short)). Not recursing further.", - file: #file, - function: #function, - line: #line - ) - processedAttributes["WARNING_recursion_stopped_max_depth"] = AnyCodable("Max depth reached.") - } - } - - // Store children directly in the AXElement attributes if they exist - if let children = childrenAXElements, !children.isEmpty { - processedAttributes["ComputedChildren"] = AnyCodable(children) - } - - return AXElement( - attributes: processedAttributes, - path: element.generatePathArray() - ) - } - - // MARK: - Attribute Fetching Logic (Internal) - - /// Internal helper to fetch and format attributes for a given element. - /// This is distinct from the global `getElementAttributes` in `AttributeHelpers.swift`. - @MainActor - internal func fetchAndFormatElementAttributes( // Renamed from getElementAttributes + internal func describeElementTree( element: Element, + appElement: Element, // For path generation context + maxDepth: Int, + currentDepth: Int, requestedAttributes: [String]?, - outputFormat: OutputFormat, - valueFormatOption: ValueFormatOption - ) -> ([String: AnyCodable], [AXLogEntry]) { - - let attributesToFetch = requestedAttributes ?? [] - + outputFormat: OutputFormat + ) -> AXElementNode { // AXElementNode would be a new struct for tree description let (attributes, _) = getElementAttributes( element: element, - attributes: attributesToFetch, - outputFormat: outputFormat, - valueFormatOption: valueFormatOption + attributes: requestedAttributes ?? AXorcist.defaultAttributesToFetch, + outputFormat: outputFormat ) + + let pathArray = element.generatePathArray(upTo: appElement) - return (attributes, []) - } - - // MARK: - Internal Helper: navigateToElement - @MainActor - internal func navigateToElement( - from startElement: Element, - pathHint: [String], - maxDepth: Int - ) -> Element? { - let pathHintString = pathHint.joined(separator: arrowSeparator) - let logMessage = "Navigating from \(startElement.briefDescription(option: .default)) with path hint: \(pathHintString)" - axDebugLog(logMessage, - file: #file, - function: #function, - line: #line - ) - - var currentElement = startElement - var currentDepth = 0 - - for (index, hintSegment) in pathHint.enumerated() { - if currentDepth >= maxDepth { - axWarningLog("Navigation max depth (\(maxDepth)) reached at segment '\(hintSegment)'. Stopping.", - file: #file, - function: #function, - line: #line - ) - return nil - } - - // Process the hint segment - let segmentResult = processHintSegment( - hintSegment: hintSegment, - currentElement: currentElement, - pathHint: pathHint, - currentIndex: index - ) - - if let foundChild = segmentResult { - currentElement = foundChild - currentDepth += 1 - } else { - return nil - } - } - - let finalPathHintString = pathHint.joined(separator: arrowSeparator) - let infoMessage = "Successfully navigated path hint: \(finalPathHintString). Final element: \(currentElement.briefDescription(option: .default))" - axInfoLog(infoMessage, file: #file, function: #function, line: #line) - return currentElement - } - - // Helper: Process a single hint segment - @MainActor - private func processHintSegment( - hintSegment: String, - currentElement: Element, - pathHint: [String], - currentIndex: Int - ) -> Element? { - guard let children = currentElement.children(), !children.isEmpty else { - let pathTraversedString = pathHint.prefix(currentIndex).joined(separator: arrowSeparator) - let errMessage = "Element \(currentElement.briefDescription(option: .short)) has no children. " + - "Cannot navigate further for hint segment '\(hintSegment)'. " + - "Path traversed so far: \(pathTraversedString)" - axDebugLog(errMessage, - file: #file, - function: #function, - line: #line - ) - return nil - } - - // Parse the hint segment - let segmentParts = hintSegment.split(separator: "=", maxSplits: 1) - let identifier = String(segmentParts[0]) - let value = segmentParts.count > 1 ? String(segmentParts[1]) : nil - - // Try to find a matching child - let matchedChild = findMatchingChildForHint( - children: children, - identifier: identifier, - value: value - ) - - if let foundChild = matchedChild { - axDebugLog("Matched segment '\(hintSegment)' to child: \(foundChild.briefDescription())", - file: #file, - function: #function, - line: #line - ) - return foundChild - } else { - let pathTraversedString = pathHint.prefix(currentIndex).joined(separator: arrowSeparator) - let warnMessage = "No child matched segment '\(hintSegment)' under \(currentElement.briefDescription(option: .short)). Path traversed: \(pathTraversedString)" - axWarningLog(warnMessage, - file: #file, - function: #function, - line: #line - ) - return nil - } - } - - // Helper: Find matching child for hint - @MainActor - private func findMatchingChildForHint( - children: [Element], - identifier: String, - value: String? - ) -> Element? { - // Handle index-based navigation - if identifier.starts(with: "@") { - return handleIndexBasedNavigation( - identifier: identifier, - children: children - ) - } - - // Handle attribute-based navigation - for child in children { - if let match = checkChildMatchesHint( - child: child, - identifier: identifier, - value: value - ) { - return match - } - } - - return nil - } - - // Helper: Handle index-based navigation - @MainActor - private func handleIndexBasedNavigation( - identifier: String, - children: [Element] - ) -> Element? { - if let indexValue = Int(identifier.dropFirst()), - indexValue >= 0, - indexValue < children.count { - let matchedChild = children[indexValue] - axDebugLog("Matched child by index \(indexValue): \(matchedChild.briefDescription())", - file: #file, - function: #function, - line: #line - ) - return matchedChild - } else { - axDebugLog("Invalid index '\(identifier)' for children count \(children.count).", - file: #file, - function: #function, - line: #line - ) - return nil - } - } - - // Helper: Check if child matches hint - @MainActor - private func checkChildMatchesHint( - child: Element, - identifier: String, - value: String? - ) -> Element? { - let attributeToMatch = determineAttributeToMatch(identifier: identifier) - let expectedValue = value - - // If no value specified, try common attributes - if value == nil && attributeToMatch == identifier { - if child.title() == identifier || - child.role() == identifier || - child.roleDescription() == identifier { - return child - } - return nil - } - - // Check attribute value - let attrName = attributeToMatch - if let valToCompare = expectedValue { - if let attrValue: String = child.attribute(Attribute(attrName)) { - if attrValue.localizedCaseInsensitiveContains(valToCompare) { - return child - } - } else if let attrValueAny = child.attribute(Attribute(attrName)) { - if String(describing: attrValueAny).localizedCaseInsensitiveContains(valToCompare) { - return child - } - } - } - - return nil - } - - // Helper: Determine which attribute to match - @MainActor - private func determineAttributeToMatch(identifier: String) -> String { - switch identifier.lowercased() { - case "role": - return AXAttributeNames.kAXRoleAttribute - case "subrole": - return AXAttributeNames.kAXSubroleAttribute - case "title": - return AXAttributeNames.kAXTitleAttribute - case "identifier": - return AXAttributeNames.kAXIdentifierAttribute - case "value": - return AXAttributeNames.kAXValueAttribute - case "description": - return AXAttributeNames.kAXDescriptionAttribute - default: - return identifier - } - } - - // MARK: - Internal Helper: findTargetElement (Common logic for locating elements) - @MainActor - internal func findTargetElement( - for appIdentifierOrNil: String?, - locator: Locator?, - pathHint: [String]?, - maxDepthForSearch: Int - ) async -> Result { - let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey - axDebugLog("findTargetElement: App=\(appIdentifier), Locator=\(String(describing: locator?.criteria)), PathHint=\(String(describing: pathHint))", file: #file, function: #function, line: #line) - - guard let appElement = applicationElement(for: appIdentifier) else { - let msg = "Application not found: \(appIdentifier)" - axErrorLog(msg, - file: #file, - function: #function, - line: #line - ) - return .failure(HandlerErrorInfo(message: msg, logs: nil)) - } - - var effectiveElement = appElement - if let hint = pathHint, !hint.isEmpty { - axDebugLog("findTargetElement: Navigating with path_hint: \(hint.joined(separator: " -> "))", - file: #file, - function: #function, - line: #line - ) - guard let navigated = navigateToElement(from: effectiveElement, pathHint: hint, maxDepth: AXMiscConstants.defaultMaxDepthSearch) else { - let msg = "Element not found via path hint: \(hint.joined(separator: " -> "))" - axErrorLog(msg, - file: #file, - function: #function, - line: #line - ) - return .failure(HandlerErrorInfo(message: msg, logs: nil)) - } - effectiveElement = navigated - axDebugLog("findTargetElement: Path hint navigated to: \(effectiveElement.briefDescription())", - file: #file, - function: #function, - line: #line - ) - } - - if let loc = locator { - var searchStartElement = effectiveElement - if let rootPathHint = loc.rootElementPathHint, !rootPathHint.isEmpty { - axDebugLog( - "findTargetElement: Locator has rootElementPathHint: " + - "\(rootPathHint.joined(separator: " -> ")). Navigating from app element.", - file: #file, - function: #function, - line: #line - ) - guard let container = navigateToElement(from: appElement, pathHint: rootPathHint, maxDepth: AXMiscConstants.defaultMaxDepthSearch) else { - let msg = "Container for locator not found via root_element_path_hint: \(rootPathHint.joined(separator: " -> "))" - axErrorLog(msg, - file: #file, - function: #function, - line: #line + var childrenNodes: [AXElementNode]? + if currentDepth < maxDepth { + if let children = element.children() { // Consider if strict:true should be an option here + childrenNodes = children.map { childElement in + describeElementTree( + element: childElement, + appElement: appElement, + maxDepth: maxDepth, + currentDepth: currentDepth + 1, + requestedAttributes: requestedAttributes, + outputFormat: outputFormat ) - return .failure(HandlerErrorInfo(message: msg, logs: nil)) } - searchStartElement = container - axDebugLog("findTargetElement: Search for locator will start from container: \(searchStartElement.briefDescription())", - file: #file, - function: #function, - line: #line - ) } - - let searchResult = self.search( // search returns SearchResult - element: searchStartElement, - locator: loc, - requireAction: loc.requireAction, - depth: 0, // Start search from depth 0 relative to searchStartElement - maxDepth: maxDepthForSearch - ) - - if let found = searchResult { - axInfoLog("findTargetElement: Found element with locator: \(found.briefDescription())", file: #file, function: #function, line: #line) - return .success(found) - } else { - let msg = "Element not found matching locator criteria: \(loc.criteria)" - axWarningLog(msg, - file: #file, - function: #function, - line: #line - ) - return .failure(HandlerErrorInfo(message: msg, logs: nil)) - } - } else { - // No locator, so the element determined by path_hint (or appElement if no path_hint) is the target. - axInfoLog("findTargetElement: No locator provided, using element from path_hint (or app root): \(effectiveElement.briefDescription())", file: #file, function: #function, line: #line) - return .success(effectiveElement) } + + // Define AXElementNode if it doesn't exist. + // For now, assuming it's similar to AXElement but with explicit children for tree. + return AXElementNode( + attributes: attributes, + path: pathArray, + children: childrenNodes + ) } } -// Helper struct for findTargetElement's error case -internal struct HandlerErrorInfo: Error { - let message: String - let logs: [String]? // This will be nil now as logs are global + +// Define AXElementNode for describeElement output (if not already defined) +// This struct represents a node in the described element tree. +public struct AXElementNode: Codable, HandlerDataRepresentable { + public var attributes: ElementAttributes? + public var path: [String]? + public var children: [AXElementNode]? // Recursive definition for children + + public init(attributes: ElementAttributes?, path: [String]? = nil, children: [AXElementNode]? = nil) { + self.attributes = attributes + self.path = path + self.children = children + } } -// NOTE: The `search` method in AXorcist.swift also needs refactoring to remove `currentDebugLogs` -// and use GlobalAXLogger. It currently returns a tuple (foundElement: Element?, logs: [String]). -// It should be changed to return just `Element?`. -// The calls to `self.search(...).foundElement` in this file anticipate that change. -// `findElementViaPathAndCriteria` (a global func) also needs this refactoring. +// Helper function to get an application element - can be shared +// This function is already available globally as applicationElement(for:) +/* +@MainActor +internal func getApplicationElement(for identifier: String) -> Element? { + return applicationElement(for: identifier) +} +*/ -// `AXorcist.formatDebugLogMessage` is no longer needed. -// `SearchVisitor` and other utility structs/classes might also have logging to update. +// Removed navigateToElement, as findTargetElement (and its internal findElementViaPathAndCriteria) +// now handles path navigation based on locator.rootElementPathHint. + +// findTargetElement should be a global function in ElementSearch.swift or similar, +// not part of AXorcist extension, to be callable by various handlers. +// For now, assuming it's accessible. It takes 'for' (appID), 'locator', 'maxDepthForSearch'. +// The pathHint parameter for findTargetElement will be removed in its own definition. + +/** + Placeholder for the global findTargetElement function. + Its actual implementation is in ElementSearch.swift and will be modified. + This is just to satisfy the compiler for this file's changes. + */ +/* +@MainActor +internal func findTargetElement( + for appIdentifier: String?, + locator: Locator, + maxDepthForSearch: Int +) async -> (element: Element?, error: String?) { + // Actual implementation will be in ElementSearch.swift + // This will use applicationElement(for:) and then findElementViaPathAndCriteria + // using locator (which includes locator.rootElementPathHint). + return (nil, "findTargetElement not yet fully refactored here") +} +*/ +// The definition of findTargetElement needs to be adjusted in ElementSearch.swift + +// Note: getElementAttributes is already a global helper. +// `search` method is part of AXorcist or ElementSearch. -// `encodeAttributesToJSONStringRepresentation` may or may not need logging changes. diff --git a/Sources/AXorcist/Logging/GlobalAXLogger+Extensions.swift b/Sources/AXorcist/Logging/GlobalAXLogger+Extensions.swift index 687c9c2..df07dba 100644 --- a/Sources/AXorcist/Logging/GlobalAXLogger+Extensions.swift +++ b/Sources/AXorcist/Logging/GlobalAXLogger+Extensions.swift @@ -89,6 +89,27 @@ extension GlobalAXLogger { public func getLogsAsJSON() async throws -> String { return try await getLogEntriesAsJSON() } + + public func getLogsAsStringsIfEnabled( + format: AXLogOutputFormat, + includeTimestamps: Bool = true, + includeLevels: Bool = true, + includeDetails: Bool = false, + includeAppName: Bool = false, + includeCommandID: Bool = false + ) async -> [String]? { + if await self.isLoggingEnabled() { + return await self.getLogsAsStrings( + format: format, + includeTimestamps: includeTimestamps, + includeLevels: includeLevels, + includeDetails: includeDetails, + includeAppName: includeAppName, + includeCommandID: includeCommandID + ) + } + return nil + } } // MARK: - Log Detail Level diff --git a/Sources/AXorcist/Logging/GlobalAXLogger.swift b/Sources/AXorcist/Logging/GlobalAXLogger.swift index 6931349..58053f1 100644 --- a/Sources/AXorcist/Logging/GlobalAXLogger.swift +++ b/Sources/AXorcist/Logging/GlobalAXLogger.swift @@ -8,6 +8,12 @@ public actor GlobalAXLogger { public static let shared = GlobalAXLogger() private var logEntries: [AXLogEntry] = [] + // For duplicate suppression + private var lastCondensedMessage: String? = nil + private var duplicateCount: Int = 0 + private let duplicateSummaryThreshold: Int = 5 + // Maximum characters to keep in a log message before truncating (for readability) + private let maxMessageLength: Int = 300 // private var subscribers: [UUID: @MainActor (AXLogEntry) -> Void] = [:] // REMOVED // Publicly accessible for direct checks if needed, though usually consumers use subscription. @@ -26,12 +32,67 @@ public actor GlobalAXLogger { // This method is called by the global ax...Log functions. // It's actor-isolated, so access to logEntries is serialized. func log(_ entry: AXLogEntry) { - logEntries.append(entry) + // Condense the message to avoid overly verbose output + let condensedMessage: String = { + if entry.message.count > maxMessageLength { + let prefix = entry.message.prefix(maxMessageLength) + return "\(prefix)… (\(entry.message.count) chars)" + } else { + return entry.message + } + }() + + // Suppress consecutive duplicate messages, but emit a summary every duplicateSummaryThreshold repeats + if let last = lastCondensedMessage, last == condensedMessage { + duplicateCount += 1 + if duplicateCount % duplicateSummaryThreshold != 0 { + return // Skip storing/logging this duplicate + } else { + let summaryEntry = AXLogEntry( + level: .debug, + message: "⟳ Previous message repeated \(duplicateSummaryThreshold) more times", + file: entry.file, + function: entry.function, + line: entry.line, + details: nil + ) + logEntries.append(summaryEntry) + // Fall through to log the duplicate after summary emission + } + } else { + // If a series of duplicates ended, optionally summarise the total count if it exceeds threshold + if duplicateCount >= duplicateSummaryThreshold && lastCondensedMessage != nil { + let summaryEntry = AXLogEntry( + level: .debug, + message: "⟳ Previous message repeated \(duplicateCount) times in total", + file: entry.file, + function: entry.function, + line: entry.line, + details: nil + ) + logEntries.append(summaryEntry) + } + // Reset duplicate tracking + lastCondensedMessage = condensedMessage + duplicateCount = 0 + } + + // Store the (potentially condensed) entry + let processedEntry = AXLogEntry( + level: entry.level, + message: condensedMessage, + file: entry.file, + function: entry.function, + line: entry.line, + details: entry.details + ) + + logEntries.append(processedEntry) // JSON logging to stderr if enabled if isJSONLoggingEnabled { do { - let jsonData = try JSONEncoder().encode(entry) + let jsonData = try JSONEncoder().encode(processedEntry) if let jsonString = String(data: jsonData, encoding: .utf8) { fputs(jsonString + "\\n", stderr) // Output JSON string to stderr } diff --git a/Sources/AXorcist/Search/ElementSearch.swift b/Sources/AXorcist/Search/ElementSearch.swift index 1960085..61e3e95 100644 --- a/Sources/AXorcist/Search/ElementSearch.swift +++ b/Sources/AXorcist/Search/ElementSearch.swift @@ -6,36 +6,146 @@ import Foundation // PathHintComponent and criteriaMatch are now in SearchCriteriaUtils.swift -// MARK: - Main Search Logic (findElementViaPathAndCriteria and its helpers) +// MARK: - Main Element Finding Orchestration + +/** + Unified function to find a target element based on application, locator (criteria and/or path hint). + This is the primary entry point for handlers. + */ +@MainActor +public func findTargetElement( + for appIdentifierOrNil: String?, + locator: Locator, + maxDepthForSearch: Int +) async -> (element: Element?, error: String?) { // Changed return type to match old handlers + let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey + axDebugLog( + "[findTargetElement ENTRY] App=\(appIdentifier), Locator: criteria=\(locator.criteria), " + + "pathHint=\(locator.rootElementPathHint?.joined(separator: "->") ?? "nil")" + ) + + guard let appElement = applicationElement(for: appIdentifier) else { + let msg = "Application not found: \(appIdentifier)" + axErrorLog(msg) + return (nil, msg) + } + + let pathHintStrings = locator.rootElementPathHint + let criteria = locator.criteria + + // Scenario 1: Only pathHint is provided (or criteria are app-specific) + let appSpecificCriteriaKeys = ["bundleId", "application", "pid", "path"] + let hasOnlyAppSpecificCriteria = criteria.isEmpty || criteria.keys.allSatisfy { appSpecificCriteriaKeys.contains($0) } + + if let hintStrings = pathHintStrings, !hintStrings.isEmpty, hasOnlyAppSpecificCriteria { + axDebugLog("findTargetElement: Using pathHint primarily as criteria are app-specific or empty.") + let pathComponents = hintStrings.compactMap { PathHintComponent(pathSegment: $0) } + if pathComponents.count != hintStrings.count { + let msg = "Invalid path hint components provided." + axWarningLog(msg) + // Fall through to regular search if path hint is malformed but criteria exist + if criteria.isEmpty { return (nil, msg) } + } else if !pathComponents.isEmpty { + if let elementFromPath = /* private */ navigateToElementByPathHint( + pathHint: pathComponents, + initialSearchElement: appElement, + pathHintMaxDepth: pathComponents.count - 1 // Navigate full path + ) { + axInfoLog("findTargetElement: Found element directly via pathHint: \(elementFromPath.briefDescription())") + // If caller specified descendantCriteria, search within the located element. + if let descCrit = locator.descendantCriteria, !descCrit.isEmpty { + axDebugLog("findTargetElement: Performing descendantCriteria search within located element. Descendant criteria: \(descCrit)") + var descLocator = Locator(criteria: descCrit) + if let descendant = traverseAndSearch(currentElement: elementFromPath, + locator: descLocator, + effectiveMaxDepth: maxDepthForSearch) { + return (descendant, nil) + } else { + return (nil, "Descendant element not found matching descendantCriteria: \(descCrit)") + } + } + return (elementFromPath, nil) + } else { + let msg = "Element not found via pathHint: \(hintStrings.joined(separator: " -> "))" + axWarningLog(msg) + return (nil, msg) // Path hint was specified but failed + } + } + } + + // Scenario 2: Criteria are present (potentially with a pathHint to narrow down search root) + // findElementViaPathAndCriteria will use pathHint from locator to find searchStartElement, + // then apply criteria. + axDebugLog("findTargetElement: Proceeding with criteria-based search (pathHint may refine start).") + if let foundElement = findElementViaPathAndCriteria( + application: appElement, + locator: locator, // This locator contains both criteria and potentially rootElementPathHint + maxDepth: maxDepthForSearch + ) { + axInfoLog("findTargetElement: Found element via criteria (and/or path): \(foundElement.briefDescription())") + var baseElement = foundElement + // Apply descendantCriteria if present + if let descCrit = locator.descendantCriteria, !descCrit.isEmpty { + axDebugLog("findTargetElement: Performing descendantCriteria search within base element. Descendant criteria: \(descCrit)") + let descLoc = Locator(criteria: descCrit) + if let descendant = traverseAndSearch(currentElement: baseElement, + locator: descLoc, + effectiveMaxDepth: maxDepthForSearch) { + baseElement = descendant + } else { + let msg = "Descendant element not found matching descendantCriteria: \(descCrit)" + axWarningLog(msg) + return (nil, msg) + } + } + + return (baseElement, nil) + } else { + let msg = "Element not found matching criteria: \(locator.criteria)" + if let hint = locator.rootElementPathHint, !hint.isEmpty { + axWarningLog("\(msg) (path hint was: \(hint.joined(separator: " -> ")))") + } else { + axWarningLog(msg) + } + return (nil, msg) + } +} + +// MARK: - Core Search Logic (findElementViaPathAndCriteria and its helpers) @MainActor -private func navigateToElementByPathHint( +/* private -> internal */ internal func navigateToElementByPathHint( pathHint: [PathHintComponent], initialSearchElement: Element, - pathHintMaxDepth: Int + pathHintMaxDepth: Int // Max depth for THIS path navigation segment ) -> Element? { var currentElementInPath = initialSearchElement axDebugLog( "PathHintNav: Starting with \(pathHint.count) components from " + - "\(initialSearchElement.briefDescription())" + "\(initialSearchElement.briefDescription()), maxNavDepth: \(pathHintMaxDepth)" ) for (index, pathComponent) in pathHint.enumerated() { - let currentNavigationDepth = index + if index > pathHintMaxDepth { // Respect max depth for this navigation + axDebugLog("PathHintNav: Max navigation depth (\(pathHintMaxDepth)) reached at component #\(index).") + return currentElementInPath // Return what we have so far + } + let criteriaDesc = pathComponent.criteria.map { "\($0.key):\($0.value)" }.joined(separator: ", ") axDebugLog( - "PathHintNav: Visiting comp #\(index), Depth:\(currentNavigationDepth), " + + "PathHintNav: Visiting comp #\(index), Depth:\(index), " + "Elem:\(currentElementInPath.briefDescription(option: .short)), " + - "Crit:\(criteriaDesc), MaxD:\(pathHintMaxDepth)" + "Crit:\(criteriaDesc))" ) + // Check if the current element in path matches the current path component + // This logic was a bit off. The component should match the *current* element, not its children. if !pathComponent.matches(element: currentElementInPath) { axDebugLog( - "PathHintNav: No match for comp #\(index), " + - "Elem:\(currentElementInPath.briefDescription(option: .short)), " + - "Crit:\(criteriaDesc))" + "PathHintNav: Current element \(currentElementInPath.briefDescription(option: .short)) " + + "does NOT match comp #\(index) Crit:\(criteriaDesc))" ) - return nil + return nil // Path broken } axDebugLog( @@ -44,35 +154,39 @@ private func navigateToElementByPathHint( "Crit:\(criteriaDesc))" ) + // If this is the last component, we've successfully navigated the path if index == pathHint.count - 1 { - return currentElementInPath // Reached end of path hint and matched + return currentElementInPath } - let nextPathComponentCriteria = pathHint[index + 1].criteria - var foundNextChild: Element? - if let children = currentElementInPath.children() { - for child in children { - let tempPathComponent = PathHintComponent(criteria: nextPathComponentCriteria) - if tempPathComponent.matches(element: child) { - currentElementInPath = child - foundNextChild = child - break - } + // Not the last component, so we need to find a child that matches the *next* component + guard let children = currentElementInPath.children() else { + axDebugLog("PathHintNav: Current element \(currentElementInPath.briefDescription(option: .short)) has no children. Cannot proceed to next component.") + return nil // Path broken, cannot find next step + } + + let nextPathComponent = pathHint[index + 1] + var foundNextChildInPath: Element? = nil + for child in children { + if nextPathComponent.matches(element: child) { + currentElementInPath = child // Advance current element + foundNextChildInPath = child + break } } - if foundNextChild == nil { - let nextCriteriaDesc = nextPathComponentCriteria - .map { "\($0.key):\($0.value)" }.joined(separator: ", ") + if foundNextChildInPath == nil { + let nextCriteriaDesc = nextPathComponent.criteria.map { "\($0.key):\($0.value)" }.joined(separator: ", ") axDebugLog( - "PathHintNav: Could not find child for next comp #\(index + 1), " + - "Under Elem:\(currentElementInPath.briefDescription(option: .short)), " + - "NextCrit:\(nextCriteriaDesc))" + "PathHintNav: Could not find child matching next comp #\(index + 1) " + + "(Crit: \(nextCriteriaDesc)) under Elem:\(currentElementInPath.briefDescription(option: .short))" ) - return nil + return nil // Path broken, cannot find next step } } - return currentElementInPath + // Should have returned from within the loop if path was fully matched or broken + // If loop finishes it means pathHint was empty or logic error + return pathHint.isEmpty ? initialSearchElement : nil } @MainActor @@ -81,8 +195,16 @@ private func traverseAndSearch( locator: Locator, effectiveMaxDepth: Int ) -> Element? { + // Ensure criteria exist if we are in traverseAndSearch. + // If only path hint was used, findTargetElement should have returned earlier. + if locator.criteria.isEmpty { + axDebugLog("traverseAndSearch: Called with empty criteria. This usually means element should have been found by path hint alone. Returning current element: \(currentElement.briefDescription())") + // This might be the element found by path hint if criteria were indeed empty. + return currentElement + } + var traverser = TreeTraverser() - let visitor = SearchVisitor(locator: locator) + let visitor = SearchVisitor(locator: locator) // SearchVisitor uses locator.criteria var traversalState = TraversalState(maxDepth: effectiveMaxDepth, startElement: currentElement) let result = traverser.traverse(from: currentElement, visitor: visitor, state: &traversalState) return result @@ -94,7 +216,7 @@ private func processPathHintAndDetermineStartElement( locator: Locator ) -> Element { guard let pathHintStrings = locator.rootElementPathHint, !pathHintStrings.isEmpty else { - axDebugLog("No path hint provided. Searching from application root.") + axDebugLog("processPathHint: No rootElementPathHint provided in locator. Searching from application root.") return application } @@ -102,51 +224,73 @@ private func processPathHintAndDetermineStartElement( guard !pathHintComponents.isEmpty && pathHintComponents.count == pathHintStrings.count else { axDebugLog( - "Path hint strings provided but failed to parse into components or " + + "processPathHint: rootElementPathHint strings provided but failed to parse into components or " + "some were invalid. Full search from app root." ) return application } - axDebugLog("Starting path hint navigation. Number of components: \(pathHintComponents.count)") + axDebugLog("processPathHint: Starting path hint navigation for search root. Number of components: \(pathHintComponents.count)") - if let elementFromPathHint = navigateToElementByPathHint( + if let elementFromPathHint = /* private -> internal */ navigateToElementByPathHint( pathHint: pathHintComponents, initialSearchElement: application, - pathHintMaxDepth: pathHintComponents.count - 1 + pathHintMaxDepth: pathHintComponents.count - 1 // Navigate the full path to find the start element ) { axDebugLog( - "Path hint navigation successful. New start: " + - "\(elementFromPathHint.briefDescription()). Starting criteria search." + "processPathHint: Path hint navigation successful. New search start: " + + "\(elementFromPathHint.briefDescription())." ) return elementFromPathHint } else { - axDebugLog("Path hint navigation failed. Full search from app root.") + axWarningLog("processPathHint: Path hint navigation failed. Full search will be from app root. Path: \(pathHintStrings.joined(separator: " -> "))") return application } } +/** + This function is the core for criteria-based search, potentially starting from an element + determined by a path hint (via locator.rootElementPathHint). + */ @MainActor -func findElementViaPathAndCriteria( +/* internal -> func */ func findElementViaPathAndCriteria( application: Element, locator: Locator, maxDepth: Int? ) -> Element? { let pathHintDebug = locator.rootElementPathHint?.joined(separator: " -> ") ?? "nil" + let criteriaDebug = locator.criteria axDebugLog( - "[findElementViaPathAndCriteria ENTRY] locator.criteria: \(locator.criteria), " + - "locator.rootElementPathHint: \(pathHintDebug) from app PID \(application.pid() ?? -1)" + "[findElementViaPathAndCriteria ENTRY] AppPID: \(application.pid() ?? -1), Locator.criteria: \(criteriaDebug), " + + "Locator.rootElementPathHint: \(pathHintDebug)" ) + // Determine the actual starting element for the criteria search. + // If locator.rootElementPathHint is present, navigate to it. Otherwise, start from app root. let searchStartElement = processPathHintAndDetermineStartElement( application: application, locator: locator ) + + // If criteria are empty at this point, it means the path hint (if any) was the sole specifier. + // The searchStartElement is our target. + if locator.criteria.isEmpty { + if locator.rootElementPathHint != nil && !locator.rootElementPathHint!.isEmpty { + axInfoLog("[findElementViaPathAndCriteria] Criteria are empty, path hint was primary. Returning element from path: \(searchStartElement.briefDescription())") + return searchStartElement // Element found by path hint is the target + } else { + axWarningLog("[findElementViaPathAndCriteria] Criteria are empty and no path hint. Returning application root by default.") + return application // Or nil if this case isn't desired + } + } + + axDebugLog("[findElementViaPathAndCriteria] Search start element: \(searchStartElement.briefDescription()). Now applying criteria: \(locator.criteria)") + let resolvedMaxDepth = maxDepth ?? AXMiscConstants.defaultMaxDepthSearch return traverseAndSearch( currentElement: searchStartElement, - locator: locator, + locator: locator, // Locator contains criteria effectiveMaxDepth: resolvedMaxDepth ) } @@ -165,12 +309,13 @@ internal func evaluateElementAgainstCriteria( depth: Int // Depth might still be useful for logical purposes, not for logging state ) -> ElementMatchStatus { - if locator.rootElementPathHint != nil, !locator.rootElementPathHint!.isEmpty { - axDebugLog( - "evaluateElement: Path hint was present in locator, assuming pre-navigated. " + - "Element: \(element.briefDescription())" - ) - } + // Path hint check here might be less relevant if pre-navigation is robust + // if locator.rootElementPathHint != nil, !locator.rootElementPathHint!.isEmpty { + // axDebugLog( + // "evaluateElement: Path hint was present in locator, assuming pre-navigated. " + + // "Element: \(element.briefDescription())" + // ) + // } if !criteriaMatch(element: element, criteria: locator.criteria) { return .noMatch @@ -182,7 +327,7 @@ internal func evaluateElementAgainstCriteria( "Element \(element.briefDescription()) matches criteria but is " + "missing required action '\(actionName)'." ) - return .noMatch + return .noMatch // Changed from partialMatchActionMissing to noMatch for stricter interpretation } axDebugLog("Element \(element.briefDescription()) matches criteria AND has required action '\(actionName)'.") } else { @@ -229,7 +374,7 @@ public func search(element: Element, */ @MainActor -public func collectAll( +/* public -> internal */ internal func collectAll( appElement: Element, // Root element of the application, for path context locator: Locator, // Criteria for matching elements (though CollectAllVisitor doesn't use it for filtering currently) @@ -247,10 +392,11 @@ public func collectAll( var traverser = TreeTraverser() var state = TraversalState(maxDepth: maxDepth, startElement: currentElement) - + // The traverse method in TreeTraverser doesn't directly use maxElements from TraversalState to stop. + // The CollectAllVisitor's visit method should implement the maxElements check. _ = traverser.traverse(from: currentElement, visitor: visitor, state: &state) - - axDebugLog("collectAll: Traversal complete. Visitor collected \(visitor.collectedElements.count) elements.") + // Result of traverse is Element? (the first one found), but for collectAll we rely on visitor's side effects. + axDebugLog("collectAll: Traversal complete. Collected \(visitor.collectedElements.count) elements.") } // Remaining functions in this file (like path navigation helpers if any outside findElementViaPathAndCriteria) diff --git a/Sources/AXorcist/Search/PathNavigator.swift b/Sources/AXorcist/Search/PathNavigator.swift index 5b37828..be191bd 100644 --- a/Sources/AXorcist/Search/PathNavigator.swift +++ b/Sources/AXorcist/Search/PathNavigator.swift @@ -13,46 +13,53 @@ private func elementMatchesAllCriteria( forPathComponent pathComponentForLog: String // For logging ) async -> Bool { let elementDescriptionForLog = element.briefDescription(option: .short) - // Explicitly log the element being checked and the criteria count - axDebugLog("PathNav/EMAC: Checking element [\(elementDescriptionForLog)] against criteria for component [\(pathComponentForLog)]. Criteria count: \(criteria.count). Criteria: \(criteria)", file: #file, function: #function, line: #line) + axDebugLog("PathNav/EMAC: Checking element [\(elementDescriptionForLog)] against criteria for component [\(pathComponentForLog)]. Criteria count: \(criteria.count). Criteria: \(criteria)") guard !criteria.isEmpty else { - axWarningLog("PathNav/EMAC: Criteria IS EMPTY for path component [\(pathComponentForLog)] on element [\(elementDescriptionForLog)]. Bailing out.", file: #file, function: #function, line: #line) - return false + axWarningLog("PathNav/EMAC: Criteria IS EMPTY for path component [\(pathComponentForLog)] on element [\(elementDescriptionForLog)]. Returning false as no criteria to match.") + return false // If criteria is empty, technically nothing to match against. } for (key, expectedValue) in criteria { if key == "PID" { // Special handling for PID - guard let actualPid_t = await element.pid() else { // Uses existing pid() -> pid_t?, await call - axDebugLog("Element [\(elementDescriptionForLog)] failed to provide PID (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line) + // If the element being checked IS the application (by its role), + // and we're checking its PID criterion from a path hint component, + // assume the PID matches because the app context is already established. + if await element.role() == AXRoleNames.kAXApplicationRole { + axDebugLog("Element [\(elementDescriptionForLog)] is AXApplication (role check). PID criterion '\(expectedValue)' from path component '\(pathComponentForLog)' considered met by context.") + continue // Skip further PID checks for the application element itself + } + + guard let actualPid_t = await element.pid() else { + axDebugLog("Element [\(elementDescriptionForLog)] failed to provide PID (for path component [\(pathComponentForLog)]). No match.") return false } - let actualPid = Int(actualPid_t) // Convert pid_t to Int for comparison + let actualPid = Int(actualPid_t) guard let expectedPid = Int(expectedValue) else { - axDebugLog("Element [\(elementDescriptionForLog)] PID criteria '\(expectedValue)' is not a valid Int (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line) + axDebugLog("Element [\(elementDescriptionForLog)] PID criteria '\(expectedValue)' is not a valid Int (for path component [\(pathComponentForLog)]). No match.") return false } if actualPid != expectedPid { - axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] != expected [\(expectedPid)] (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line) + axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] != expected [\(expectedPid)] (for path component [\(pathComponentForLog)]). No match.") return false } - axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] == expected [\(expectedPid)] (for path component [\(pathComponentForLog)]). Criterion met.", file: #file, function: #function, line: #line) - } else { // Handle other attributes as before - let fetchedAttributeValue: String? = await element.attribute(Attribute(key)) // await call - axDebugLog("PathNav/EMAC: For element [\(elementDescriptionForLog)], component [\(pathComponentForLog)], attr [\(key)], fetched value is: [\(String(describing: fetchedAttributeValue))]. About to check if nil.", file: #file, function: #function, line: #line) // NEW DETAILED LOG + axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] == expected [\(expectedPid)] (for path component [\(pathComponentForLog)]). Criterion met.") + } else { // Handle other attributes + let fetchedAttributeValue: String? = await element.attribute(Attribute(key)) + axDebugLog("PathNav/EMAC: For element [\(elementDescriptionForLog)], component [\(pathComponentForLog)], attr [\(key)], fetched value is: [\(String(describing: fetchedAttributeValue))].") - guard let actualValue = fetchedAttributeValue else { - axDebugLog("Element [\(elementDescriptionForLog)] lacks attribute [\(key)] (value was nil after fetch) for path component [\(pathComponentForLog)]. No match.", file: #file, function: #function, line: #line) // Modified log + guard let actualValue = fetchedAttributeValue else { + axDebugLog("Element [\(elementDescriptionForLog)] lacks attribute [\(key)] (value was nil after fetch) for path component [\(pathComponentForLog)]. No match.") return false } if actualValue != expectedValue { - axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] value [\(actualValue)] != expected [\(expectedValue)] (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line) + axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] value [\(actualValue)] != expected [\(expectedValue)] (for path component [\(pathComponentForLog)]). No match.") return false } - axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] value [\(actualValue)] == expected [\(expectedValue)] (for path component [\(pathComponentForLog)]). Criterion met.", file: #file, function: #function, line: #line) + axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] value [\(actualValue)] == expected [\(expectedValue)] (for path component [\(pathComponentForLog)]). Criterion met.") } } - axDebugLog("Element [\(elementDescriptionForLog)] matches ALL criteria for path component [\(pathComponentForLog)]. Match!", file: #file, function: #function, line: #line) + axDebugLog("Element [\(elementDescriptionForLog)] matches ALL criteria for path component [\(pathComponentForLog)]. Match!") return true } @@ -144,8 +151,7 @@ private func processPathComponent( axDebugLog("PathNav/PPC: Step \(stepCounter). After logPathComponentProcessing. Before PRE-CALL FMIC.") stepCounter += 1 - axDebugLog("PathNav/PPC: PRE-CALL FMIC (SIMPLE)", file: #file, function: #function, line: #line) - try? await Task.sleep(nanoseconds: 100_000_000) // Diagnostic delay + axDebugLog("PathNav/PPC: PRE-CALL FMIC", file: #file, function: #function, line: #line) axDebugLog("PathNav/PPC: Step \(stepCounter). After PRE-CALL FMIC. Before findMatchingChild call.") stepCounter += 1 @@ -218,8 +224,7 @@ private func findMatchingChild( criteriaToMatch: [String: String], pathComponentForLog: String // Pass for logging inside elementMatchesAllCriteria ) async -> Element? { - axDebugLog("PathNav/FMIC: ABSOLUTE ENTRY (SIMPLE)", file: #file, function: #function, line: #line) - try? await Task.sleep(nanoseconds: 100_000_000) // Diagnostic delay + axDebugLog("PathNav/FMIC: ABSOLUTE ENTRY", file: #file, function: #function, line: #line) axDebugLog("PathNav/FMIC: Entered function for component [\(pathComponentForLog)]. Criteria: \(criteriaToMatch)", file: #file, function: #function, line: #line) diff --git a/Sources/AXorcist/Search/SearchCriteriaUtils.swift b/Sources/AXorcist/Search/SearchCriteriaUtils.swift index 8140bb2..c0197d3 100644 --- a/Sources/AXorcist/Search/SearchCriteriaUtils.swift +++ b/Sources/AXorcist/Search/SearchCriteriaUtils.swift @@ -10,27 +10,58 @@ public struct PathHintComponent { public let criteria: [String: String] public let originalSegment: String // Added to store the original segment - // Refactored initializer + /// Aliases mapping human-readable keys (as produced by Accessibility Inspector) to actual AX attribute names. + private static let attributeAliases: [String: String] = [ + // Common role/title identifiers that use ':' delimiter in Inspector output + "Role": AXAttributeNames.kAXRoleAttribute, + "Title": AXAttributeNames.kAXTitleAttribute, + "Subrole": AXAttributeNames.kAXSubroleAttribute, + "Identifier": AXAttributeNames.kAXIdentifierAttribute, + "DOMId": AXAttributeNames.kAXDOMIdentifierAttribute, + // PID is handled specially elsewhere, keep as-is + "PID": "PID" + ] + public init?(pathSegment: String) { - self.originalSegment = pathSegment // Store original segment - var parsedCriteria: [String: String] = [:] - let pairs = pathSegment.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - for pair in pairs { - let keyValue = pair.split(separator: "=", maxSplits: 1) + self.originalSegment = pathSegment + + // First, try to parse with PathUtils.parseRichPathComponent which supports ':' delimiters + var parsedCriteria = PathUtils.parseRichPathComponent(pathSegment) + + // Fallback – older format that uses '=' as delimiter + if parsedCriteria.isEmpty { + let fallbackPairs = pathSegment + .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - if keyValue.count == 2 { - parsedCriteria[String(keyValue[0])] = String(keyValue[1]) - } else { - axDebugLog("PathHintComponent: Invalid key-value pair: \(pair)") + for pair in fallbackPairs { + let keyValue = pair.split(separator: "=", maxSplits: 1) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + if keyValue.count == 2 { + parsedCriteria[String(keyValue[0])] = String(keyValue[1]) + } } } - if parsedCriteria.isEmpty && !pathSegment.isEmpty { - axDebugLog("PathHintComponent: Path segment \"\(pathSegment)\" parsed into empty criteria.") + + // Apply alias mapping so that keys line up with real AX attribute names expected by the matcher. + var mappedCriteria: [String: String] = [:] + for (rawKey, value) in parsedCriteria { + if let mappedKey = Self.attributeAliases[rawKey] { + mappedCriteria[mappedKey] = value + } else { + mappedCriteria[rawKey] = value + } } - self.criteria = parsedCriteria - let criteriaForLog = self.criteria - let segmentForLog = pathSegment - axDebugLog("PathHintComponent initialized with criteria: \(criteriaForLog) from segment: \(segmentForLog)") + + // If still empty after parsing/mapping, return nil so that the component is ignored by caller. + if mappedCriteria.isEmpty { + axWarningLog("PathHintComponent: Path segment '\(pathSegment)' produced no usable criteria after parsing.") + return nil + } + + self.criteria = mappedCriteria + + let critDesc = mappedCriteria + axDebugLog("PathHintComponent initialized. Segment: '\(pathSegment)' => criteria: \(critDesc)") } // Convenience initializer if criteria is already a dictionary @@ -47,57 +78,119 @@ public struct PathHintComponent { // MARK: - Criteria Matching Helper @MainActor -func criteriaMatch(element: Element, criteria: [String: String]?) -> Bool { +public func criteriaMatch( + element: Element, + criteria: [String: String]?, + matchAll: Bool? = true, + appProcessId: pid_t? = nil +) -> Bool { guard let criteria = criteria, !criteria.isEmpty else { return true // No criteria means an automatic match } - for (key, expectedValue) in criteria { - if key == AXAttributeNames.kAXRoleAttribute && expectedValue == "*" { continue } // Wildcard for role + let elementDescriptionForLog = element.briefDescription(option: .short) + axDebugLog("criteriaMatch: Checking element [\(elementDescriptionForLog)] against criteria. Criteria count: \(criteria.count). Criteria: \(criteria)") - if key == "IsClickable" { // Computed property + for (key, expectedValue) in criteria { + if key == AXAttributeNames.kAXRoleAttribute && expectedValue == "*" { + axDebugLog("criteriaMatch: Wildcard for role attribute matched.") + continue // Wildcard for role + } + + // Special handling for PID, similar to elementMatchesAllCriteria + if key == "PID" { + if element.role() == AXRoleNames.kAXApplicationRole { + axDebugLog("Element [\(elementDescriptionForLog)] is AXApplication. PID criterion '\(expectedValue)' considered met by context.") + continue + } + guard let actualPid_t = element.pid() else { + axDebugLog("Element [\(elementDescriptionForLog)] failed to provide PID. No match for key 'PID'.") + return false + } + let actualPid = Int(actualPid_t) + guard let expectedPid = Int(expectedValue) else { + axDebugLog("Element [\(elementDescriptionForLog)] PID criteria '\(expectedValue)' is not a valid Int. No match for key 'PID'.") + return false + } + if actualPid != expectedPid { + axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] != expected [\(expectedPid)]. No match for key 'PID'.") + return false + } + axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] == expected [\(expectedPid)]. Criterion met for key 'PID'.") + continue // PID matched, move to next criterion + } + + // Handle "IsClickable" as a computed property + if key == "IsClickable" { let supportsPress = element.isActionSupported(AXActionNames.kAXPressAction) let expectedBoolValue = (expectedValue.lowercased() == "true") if supportsPress == expectedBoolValue { - axDebugLog( - "Computed criteria 'IsClickable' (via AXPress support) matched: " + - "Expected '\(expectedValue)', Got '\(supportsPress)'." - ) + axDebugLog("Computed criteria 'IsClickable' (via AXPress support) matched: Expected '\(expectedValue)', Got '\(supportsPress)'.") continue } else { - axDebugLog( - "Computed criteria 'IsClickable' (via AXPress support) mismatch: " + - "Expected '\(expectedValue)', Got '\(supportsPress)'. " + - "Element: \(element.briefDescription(option: .default)). No match." - ) + axDebugLog("Computed criteria 'IsClickable' (via AXPress support) mismatch: Expected '\(expectedValue)', Got '\(supportsPress)'. Element: \(elementDescriptionForLog). No match.") return false } } - // Removed unused variable: var attributeValueCFType: CFTypeRef? - let rawValue = element.rawAttributeValue(named: key) + // For other attributes, fetch as String and perform exact match + let fetchedAttributeValue: String? = element.attribute(Attribute(key)) + axDebugLog("criteriaMatch: For element [\(elementDescriptionForLog)], attr [\(key)], fetched value is: [\(String(describing: fetchedAttributeValue))]. Expected: [\(expectedValue)]") - guard let actualValueCF = rawValue else { - axDebugLog( - "Attribute \(key) not found or error on element " + - "\(element.briefDescription(option: .default)). No match." - ) + guard let actualValue = fetchedAttributeValue else { + // If attribute is not present, it's a mismatch unless expectedValue indicates absence (e.g., "~nil" or "~empty") + // or if a regex is used that could potentially match an empty string (though attribute must exist). + if expectedValue.lowercased() == "~nil" { + axDebugLog("Element [\(elementDescriptionForLog)] lacks attribute [\(key)]. Expected '~nil'. Criterion met.") + continue + } + // If expecting a regex match, the attribute must exist. + if expectedValue.starts(with: "~regex:") { + axDebugLog("Element [\(elementDescriptionForLog)] lacks attribute [\(key)] (value was nil after fetch). Expected regex match for '\(expectedValue)'. No match.") + return false + } + axDebugLog("Element [\(elementDescriptionForLog)] lacks attribute [\(key)] (value was nil after fetch). Expected '\(expectedValue)'. No match.") return false } - let actualValueSwift: Any? = ValueUnwrapper.unwrap(actualValueCF) - let actualValueString = String(describing: actualValueSwift ?? "nil_after_unwrap") + // Handle ~empty explicitly, before regex, as regex could also match empty. + if expectedValue.lowercased() == "~empty" { + if actualValue.isEmpty { + axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] is empty. Expected '~empty'. Criterion met.") + continue + } else { + axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] value [\(actualValue)] is not empty. Expected '~empty'. No match.") + return false + } + } - if !(actualValueString.localizedCaseInsensitiveContains(expectedValue) || actualValueString == expectedValue) { - axDebugLog( - "Attribute '\(key)' mismatch: Expected '\(expectedValue)', " + - "Got '\(actualValueString)'. " + - "Element: \(element.briefDescription(option: .default)). No match." - ) + // Regular Expression Matching + if expectedValue.starts(with: "~regex:") { + let pattern = String(expectedValue.dropFirst("~regex:".count)) + do { + // Default to case-insensitive matching for UI elements, which is generally more useful. + let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive) + let range = NSRange(actualValue.startIndex.. TraversalAction { + // Check against filterCriteria if provided + if let criteria = self.filterCriteria, !criteria.isEmpty { + // Assuming SearchCriteriaUtils.criteriaMatch is accessible here. + // If not, this logic needs to be adapted or the function made available. + // Defaulting matchAll to true, as filters usually imply all conditions must pass. + let matchesFilter = SearchCriteriaUtils.criteriaMatch( + element: element, + criteria: criteria, + matchAll: true, + appProcessId: element.pid() // Pass PID for context if needed by criteriaMatch + ) + if !matchesFilter { + axDebugLog("[CollectAllVisitor] Element \(element.briefDescription()) did NOT match filterCriteria. Skipping.") + return .continueTraversal // Skip this element, but continue traversal for its children + } + axDebugLog("[CollectAllVisitor] Element \(element.briefDescription()) MATCHED filterCriteria.") + } + // getElementAttributes is now a global function let (fetchedAttrs, _) = getElementAttributes( element: element, diff --git a/Sources/axorc/AXORCMain.swift b/Sources/axorc/AXORCMain.swift index 02d5933..1f0bbeb 100644 --- a/Sources/axorc/AXORCMain.swift +++ b/Sources/axorc/AXORCMain.swift @@ -106,15 +106,8 @@ struct AXORCCommand: AsyncParsableCommand { // Handle input errors if let error = inputResult.error { let collectedLogs = debug ? await GlobalAXLogger.shared.getLogsAsStrings(format: .text, includeTimestamps: true, includeLevels: true, includeDetails: true) : nil - - let errorResponse = ErrorResponse( - commandId: "input_error", - error: error, - debugLogs: collectedLogs - ) - - if let jsonData = try? JSONEncoder().encode(errorResponse), - let jsonString = String(data: jsonData, encoding: .utf8) { + let errorResponse = ErrorResponse(commandId: "input_error", error: error, debugLogs: collectedLogs) + if let jsonData = try? JSONEncoder().encode(errorResponse), let jsonString = String(data: jsonData, encoding: .utf8) { print(jsonString) } else { print("{\"error\": \"Failed to encode error response\"}") @@ -122,97 +115,50 @@ struct AXORCCommand: AsyncParsableCommand { return } - guard var jsonStringFromInput = inputResult.jsonString else { + guard let jsonStringFromInput = inputResult.jsonString else { let collectedLogs = debug ? await GlobalAXLogger.shared.getLogsAsStrings(format: .text, includeTimestamps: true, includeLevels: true, includeDetails: true) : nil - - let errorResponse = ErrorResponse( - commandId: "no_input", - error: "No valid JSON input received", - debugLogs: collectedLogs - ) - - if let jsonData = try? JSONEncoder().encode(errorResponse), - let jsonStr = String(data: jsonData, encoding: .utf8) { + let errorResponse = ErrorResponse(commandId: "no_input", error: "No valid JSON input received", debugLogs: collectedLogs) + if let jsonData = try? JSONEncoder().encode(errorResponse), let jsonStr = String(data: jsonData, encoding: .utf8) { print(jsonStr) } else { print("{\"error\": \"Failed to encode error response\"}") } return } - axDebugLog("AXORCMain: jsonStringFromInput (from InputHandler): [\(jsonStringFromInput)] (length: \(jsonStringFromInput.count))") + axDebugLog("AXORCMain Test: Received jsonStringFromInput: [\(jsonStringFromInput)] (length: \(jsonStringFromInput.count))") - // Ensure we are working with a "concrete" String instance to avoid Substring/StringProtocol ambiguities - var jsonString = String(jsonStringFromInput) - axDebugLog("AXORCMain: jsonString (after String(jsonStringFromInput)): [\(jsonString)] (length: \(jsonString.count))") - - // Log first/last chars of the concrete jsonString - if !jsonString.isEmpty { - axDebugLog("AXORCMain: First char of concrete jsonString: \(jsonString.first!) (ASCII: \(jsonString.first!.asciiValue ?? 0)), Last char: \(jsonString.last!) (ASCII: \(jsonString.last!.asciiValue ?? 0))") - } - - // Parse JSON command - var dataToDecode = jsonString.data(using: .utf8) // Default to using the concrete jsonString - var didAttemptUnwrap = false - - if jsonString.hasPrefix("[") && jsonString.hasSuffix("]") && jsonString.count > 2 { // Use concrete jsonString for checks - let innerContentString = String(jsonString.dropFirst().dropLast()) - axDebugLog("AXORCMain: Original concrete jsonString appeared to be an array. Attempting to use its inner content: [\(innerContentString)]") - if let innerData = innerContentString.data(using: .utf8) { - dataToDecode = innerData - didAttemptUnwrap = true - } else { - axDebugLog("AXORCMain: Failed to convert innerContentString to data. Will use original concrete jsonString data.") + if let data = jsonStringFromInput.data(using: .utf8) { + let axorcist = AXorcist() + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + do { + // Attempt 1: Decode as [CommandEnvelope] + let commands = try decoder.decode([CommandEnvelope].self, from: data) + if let command = commands.first { + axDebugLog("AXORCMain Test: Decode attempt 1: Successfully decoded [CommandEnvelope] and got first command.") + await processAndExecuteCommand(command: command, axorcist: axorcist, debugCLI: debug) + } else { + axDebugLog("AXORCMain Test: Decode attempt 1: Decoded [CommandEnvelope] but array was empty.") + // Create a generic error to throw if this path is problematic + let anError = NSError(domain: "AXORCErrorDomain", code: 1001, userInfo: [NSLocalizedDescriptionKey: "Decoded empty command array from [CommandEnvelope] attempt."]) + throw anError + } + } catch let arrayDecodeError { + axDebugLog("AXORCMain Test: Decode attempt 1 (as [CommandEnvelope]) FAILED. Error: \(arrayDecodeError). Will try as single CommandEnvelope.") + // Attempt 2: Decode as single CommandEnvelope + do { + let command = try decoder.decode(CommandEnvelope.self, from: data) // data is still from jsonStringFromInput + axDebugLog("AXORCMain Test: Decode attempt 2: Successfully decoded as SINGLE CommandEnvelope.") + await processAndExecuteCommand(command: command, axorcist: axorcist, debugCLI: debug) + } catch let singleDecodeError { + axDebugLog("AXORCMain Test: Decode attempt 2 (as single CommandEnvelope) ALSO FAILED. Error: \(singleDecodeError). Original array decode error was: \(arrayDecodeError)") + throw singleDecodeError // Throw the error from the single decode attempt as it's the most direct if input was not an array + } } } else { - axDebugLog("AXORCMain: Original concrete jsonString does not appear to be a simple array wrapper. Proceeding with it for data conversion.") + axDebugLog("AXORCMain Test: Failed to convert jsonStringFromInput to data.") + let anError = NSError(domain: "AXORCErrorDomain", code: 1002, userInfo: [NSLocalizedDescriptionKey: "Failed to convert jsonStringFromInput to data."]) + throw anError } - - // axDebugLog("AXORCMain: effectiveJsonString after unwrap attempt: [\(effectiveJsonString)]") // Old log, dataToDecode is now key - - guard let jsonData = dataToDecode else { - // Clear logs after error - await axClearLogs() - print("{\"error\": \"Failed to convert JSON string to data\"}") - return - } - - if debug { - axDebugLog("AXORCMain: jsonData.count before decode (this is from effective/unwrapped data if unwrap occurred): \(jsonData.count)") - } - - let axorcist = AXorcist() // Initialize once, outside the do-catch for broader scope - - do { - // This is the primary attempt, using `jsonData` (derived from `dataToDecode`, - // which is from `jsonString` after faulty unwrap attempt due to string issues) - let command = try JSONDecoder().decode(CommandEnvelope.self, from: jsonData) - axDebugLog("AXORCMain: Decode attempt 1 (from jsonData derived from potentially pre-unwrapped jsonString) successful.") - await processAndExecuteCommand(command: command, axorcist: axorcist, debugCLI: debug) - } catch let error1 { - axDebugLog("AXORCMain: Decode attempt 1 (from jsonData) FAILED. Error: \(error1). jsonStringFromInput (raw from InputHandler) was: [\(jsonStringFromInput)]") - // Fallback: Assume jsonStringFromInput is "[{...}]" because InputHandler (via ArgumentParser) seems to yield this. - // Try to extract "{...}" and decode that as a single CommandEnvelope. - if jsonStringFromInput.count > 2 { // Basic check for "[]" at least - let potentiallyInnerJsonString = String(jsonStringFromInput.dropFirst().dropLast()) - axDebugLog("AXORCMain: Fallback: Extracted potentiallyInnerJsonString from jsonStringFromInput: [\(potentiallyInnerJsonString)]") - if let innerData = potentiallyInnerJsonString.data(using: .utf8) { - do { - let command = try JSONDecoder().decode(CommandEnvelope.self, from: innerData) - axDebugLog("AXORCMain: Decode attempt 2 (from inner content of jsonStringFromInput) SUCCESSFUL.") - await processAndExecuteCommand(command: command, axorcist: axorcist, debugCLI: debug) - } catch let error2 { - axDebugLog("AXORCMain: Decode attempt 2 (from inner content of jsonStringFromInput) FAILED. Error: \(error2). Will rethrow original error from attempt 1.") - throw error1 // Rethrow original error from attempt 1 - } - } else { - axDebugLog("AXORCMain: Fallback: Failed to convert potentiallyInnerJsonString to data. Will rethrow original error from attempt 1.") - throw error1 // Rethrow original error from attempt 1 - } - } else { - axDebugLog("AXORCMain: Fallback: jsonStringFromInput too short to be '[{...}]'. Will rethrow original error from attempt 1.") - throw error1 // Rethrow original error from attempt 1 - } - } - // Removed the final generic catch to ensure errors propagate to ArgumentParser if not handled by the specific fallback. } } diff --git a/Sources/axorc/Core/CommandExecutor.swift b/Sources/axorc/Core/CommandExecutor.swift index a6a8dd3..59ab8cd 100644 --- a/Sources/axorc/Core/CommandExecutor.swift +++ b/Sources/axorc/Core/CommandExecutor.swift @@ -47,10 +47,17 @@ struct CommandExecutor { } private static func setupLogging(for command: CommandEnvelope) async -> (Bool, AXLogDetailLevel) { + // DIAGNOSTIC LOG: Print the received value of command.debugLogging + fputs("CommandExecutor.setupLogging: Received command.debugLogging = \(command.debugLogging)\n", stderr) + fflush(stderr) // Ensure it prints immediately for CLI debugging + + // Also log it via axDebugLog so it becomes part of the collected logs + axDebugLog("[CommandExecutor.setupLogging] Received command.debugLogging = \(command.debugLogging)") + let initialLoggingEnabled = await GlobalAXLogger.shared.isLoggingEnabled() let initialDetailLevel = await GlobalAXLogger.shared.getDetailLevel() - if let cmdDebug = command.debugLogging, cmdDebug { + if command.debugLogging { await GlobalAXLogger.shared.setLoggingEnabled(true) await GlobalAXLogger.shared.setDetailLevel(.verbose) } @@ -94,12 +101,12 @@ struct CommandExecutor { return await axorcist.handleCollectAll( for: command.application, locator: command.locator, - pathHint: command.pathHint, // from CommandEnvelope maxDepth: command.maxDepth, requestedAttributes: command.attributes, outputFormat: command.outputFormat, commandId: command.commandId, - debugCLI: debugCLI // Pass the flag + debugCLI: debugCLI, // Pass the flag + filterCriteria: command.filterCriteria // ADDED ) case .batch: @@ -117,6 +124,9 @@ struct CommandExecutor { case .observe: // Pass debugCLI to handler return await handleObserveCommand(command: command, axorcist: axorcist, debugCLI: debugCLI) + + case .setFocusedValue: + return await handleSimpleCommand(command: command, axorcist: axorcist, debugCLI: debugCLI, executor: executeSetFocusedValue) } } @@ -125,19 +135,14 @@ struct CommandExecutor { let error = "Missing actionName for performAction" axErrorLog(error) // Log error // Conditionally include logs in error response based on debugCLI - let logsToInclude = debugCLI ? await GlobalAXLogger.shared.getLogsAsStringsIfEnabled(format: .text, includeTimestamps: false, includeLevels: false) : nil - let queryResponse = QueryResponse( - success: false, + let errorResponse = HandlerResponse(data: nil, error: error) + return await finalizeAndEncodeResponse( commandId: command.commandId, - command: command.command.rawValue, - error: error, // This is a String, QueryResponse legacy init handles String for error - debugLogs: logsToInclude + commandType: command.command.rawValue, + handlerResponse: errorResponse, + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging ) - let jsonString = encodeToJson(queryResponse) - let fallbackJson = """ - {"error": "Encoding error response failed"} - """ - return jsonString ?? fallbackJson } let handlerResponse = await executePerformAction( @@ -150,7 +155,8 @@ struct CommandExecutor { commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: handlerResponse, - debugCLI: debugCLI + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging ) } @@ -161,12 +167,13 @@ struct CommandExecutor { executor: (CommandEnvelope, AXorcist) async -> HandlerResponse ) async -> String { let handlerResponse = await executor(command, axorcist) - // Pass debugCLI to finalizeAndEncodeResponse + // Pass debugCLI and command.debugLogging to finalizeAndEncodeResponse return await finalizeAndEncodeResponse( commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: handlerResponse, - debugCLI: debugCLI + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging ) } @@ -199,12 +206,13 @@ struct CommandExecutor { data: nil, error: nil ) - // Pass debugCLI to finalizeAndEncodeResponse + // Pass debugCLI and command.debugLogging to finalizeAndEncodeResponse return await finalizeAndEncodeResponse( commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: pingHandlerResponse, - debugCLI: debugCLI + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging ) } @@ -213,12 +221,13 @@ struct CommandExecutor { data: nil, error: message ) - // Pass debugCLI to finalizeAndEncodeResponse + // Pass debugCLI and command.debugLogging to finalizeAndEncodeResponse return await finalizeAndEncodeResponse( commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: notImplementedResponse, - debugCLI: debugCLI + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging ) } @@ -229,29 +238,33 @@ struct CommandExecutor { axorcist: AXorcist, actionName: String ) async -> HandlerResponse { - guard let locator = command.locator else { - let error = "Missing locator for performAction" + var locator: Locator? = command.locator + + // If pathHint is valid and locator is nil, create a default empty locator + if let pathHint = command.pathHint, !pathHint.isEmpty, locator == nil { + locator = Locator(criteria: [:]) + axDebugLog("CommandExecutor: Created default empty locator because pathHint was provided but locator was nil.") + } + + // If locator is still nil (no pathHint provided and no locator in command), return error + guard var validLocator = locator else { + let error = "Missing locator or pathHint for performAction" axErrorLog(error) return HandlerResponse(data: nil, error: error) } - // Convert path_hint from [String] to [PathHintComponent] if needed - var pathHintComponents: [PathHintComponent]? - if let pathHints = command.pathHint { - pathHintComponents = [] - for hint in pathHints { - if let component = await PathHintComponent(pathSegment: hint) { - pathHintComponents?.append(component) - } - } + // If CommandEnvelope.pathHint is provided, and locator.rootElementPathHint is not, + // transfer the pathHint to the locator. + if let topLevelPathHint = command.pathHint, !topLevelPathHint.isEmpty, validLocator.rootElementPathHint == nil { + axDebugLog("CommandExecutor: Populating locator.rootElementPathHint from CommandEnvelope.pathHint.") + validLocator.rootElementPathHint = topLevelPathHint } - return await axorcist.handlePerformAction( // This handler uses GlobalAXLogger + return await axorcist.handlePerformAction( for: command.application, - locator: locator, + locator: validLocator, actionName: actionName, actionValue: command.actionValue, - pathHint: pathHintComponents, maxDepth: command.maxElements ) } @@ -260,7 +273,7 @@ struct CommandExecutor { command: CommandEnvelope, axorcist: AXorcist ) async -> HandlerResponse { - return await axorcist.handleGetFocusedElement( // This handler uses GlobalAXLogger + return await axorcist.handleGetFocusedElement( for: command.application, requestedAttributes: command.attributes ) @@ -270,17 +283,22 @@ struct CommandExecutor { command: CommandEnvelope, axorcist: AXorcist ) async -> HandlerResponse { - guard let locator = command.locator else { - let error = "Missing locator for getAttributes" - axErrorLog(error) - return HandlerResponse(data: nil, error: error) + guard var locator = command.locator else { + axErrorLog("Missing locator for getAttributes") + return HandlerResponse(data: nil, error: "Missing locator for getAttributes") } - return await axorcist.handleGetAttributes( // This handler uses GlobalAXLogger + // If CommandEnvelope.pathHint is provided, and locator.rootElementPathHint is not, + // transfer the pathHint to the locator. + if let topLevelPathHint = command.pathHint, !topLevelPathHint.isEmpty, locator.rootElementPathHint == nil { + axDebugLog("CommandExecutor: Populating locator.rootElementPathHint from CommandEnvelope.pathHint for getAttributes.") + locator.rootElementPathHint = topLevelPathHint + } + + return await axorcist.handleGetAttributes( for: command.application, locator: locator, requestedAttributes: command.attributes, - pathHint: command.pathHint, - maxDepth: command.maxElements, + maxDepth: command.maxDepth, outputFormat: command.outputFormat ) } @@ -289,16 +307,21 @@ struct CommandExecutor { command: CommandEnvelope, axorcist: AXorcist ) async -> HandlerResponse { - guard let locator = command.locator else { - let error = "Missing locator for query" - axErrorLog(error) - return HandlerResponse(data: nil, error: error) + guard var locator = command.locator else { + axErrorLog("Missing locator for query") + return HandlerResponse(data: nil, error: "Missing locator for query") } - return await axorcist.handleQuery( // This handler uses GlobalAXLogger + // If CommandEnvelope.pathHint is provided, and locator.rootElementPathHint is not, + // transfer the pathHint to the locator. + if let topLevelPathHint = command.pathHint, !topLevelPathHint.isEmpty, locator.rootElementPathHint == nil { + axDebugLog("CommandExecutor: Populating locator.rootElementPathHint from CommandEnvelope.pathHint for query.") + locator.rootElementPathHint = topLevelPathHint + } + + return await axorcist.handleQuery( for: command.application, locator: locator, - pathHint: command.pathHint, - maxDepth: command.maxElements, + maxDepth: command.maxDepth, requestedAttributes: command.attributes, outputFormat: command.outputFormat ) @@ -308,16 +331,21 @@ struct CommandExecutor { command: CommandEnvelope, axorcist: AXorcist ) async -> HandlerResponse { - guard let locator = command.locator else { - let error = "Missing locator for describeElement" - axErrorLog(error) - return HandlerResponse(data: nil, error: error) + guard var locator = command.locator else { + axErrorLog("Missing locator for describeElement") + return HandlerResponse(data: nil, error: "Missing locator for describeElement") } - return await axorcist.handleDescribeElement( // This handler uses GlobalAXLogger + // If CommandEnvelope.pathHint is provided, and locator.rootElementPathHint is not, + // transfer the pathHint to the locator. + if let topLevelPathHint = command.pathHint, !topLevelPathHint.isEmpty, locator.rootElementPathHint == nil { + axDebugLog("CommandExecutor: Populating locator.rootElementPathHint from CommandEnvelope.pathHint for describeElement.") + locator.rootElementPathHint = topLevelPathHint + } + + return await axorcist.handleDescribeElement( for: command.application, locator: locator, - pathHint: command.pathHint, - maxDepth: command.maxElements, + maxDepth: command.maxDepth, requestedAttributes: command.attributes, outputFormat: command.outputFormat ) @@ -327,26 +355,21 @@ struct CommandExecutor { command: CommandEnvelope, axorcist: AXorcist ) async -> HandlerResponse { - guard let locator = command.locator else { - let error = "Missing locator for extractText" - axErrorLog(error) - return HandlerResponse(data: nil, error: error) + guard var locator = command.locator else { + axErrorLog("Missing locator for extractText") + return HandlerResponse(data: nil, error: "Missing locator for extractText") } - // Convert path_hint from [String] to [PathHintComponent] if needed - var pathHintComponents: [PathHintComponent]? - if let pathHints = command.pathHint { - pathHintComponents = [] - for hint in pathHints { - if let component = await PathHintComponent(pathSegment: hint) { - pathHintComponents?.append(component) - } - } + // If CommandEnvelope.pathHint is provided, and locator.rootElementPathHint is not, + // transfer the pathHint to the locator. + if let topLevelPathHint = command.pathHint, !topLevelPathHint.isEmpty, locator.rootElementPathHint == nil { + axDebugLog("CommandExecutor: Populating locator.rootElementPathHint from CommandEnvelope.pathHint for extractText.") + locator.rootElementPathHint = topLevelPathHint } - - return await axorcist.handleExtractText( // This handler uses GlobalAXLogger + + return await axorcist.handleExtractText( for: command.application, locator: locator, - pathHint: pathHintComponents + maxDepth: command.maxDepth ) } @@ -408,15 +431,101 @@ struct CommandExecutor { ) } + // MARK: - NEW COMMAND: setFocusedValue + + private static func executeSetFocusedValue( + command: CommandEnvelope, + axorcist: AXorcist + ) async -> HandlerResponse { + // 1. Retrieve the currently-focused element in the target application + let focusedResp = await axorcist.handleGetFocusedElement(for: command.application, requestedAttributes: nil) + if let err = focusedResp.error { + return HandlerResponse(data: nil, error: "Failed to fetch focused element: \(err)") + } + + guard let raw = focusedResp.data?.value as? Element else { + return HandlerResponse(data: nil, error: "Focused element missing from response or not the correct Element type") + } + + // 2. Determine the operation + let actionName = command.actionName ?? "AXSetValue" + + // We handle the most common cases directly to avoid another brittle lookup + // Standard press-style actions + let standardActions: Set = [ + AXActionNames.kAXPressAction, + AXActionNames.kAXPickAction, + AXActionNames.kAXConfirmAction, + AXActionNames.kAXCancelAction, + AXActionNames.kAXIncrementAction, + AXActionNames.kAXDecrementAction, + AXActionNames.kAXShowMenuAction, + AXActionNames.kAXRaiseAction + ] + + if standardActions.contains(actionName) { + let status = AXUIElementPerformAction(raw.underlyingElement, actionName as CFString) + if status == .success { + return HandlerResponse(data: AnyCodable(PerformResponse(commandId: command.commandId, success: true))) + } else { + return HandlerResponse(error: "AX action \(actionName) failed: \(axErrorToString(status))") + } + } else { + // Treat actionName as an attribute to be set (e.g., AXSetValue / AXValue) + let attrName = (actionName == "AXSetValue") ? AXAttributeNames.kAXValueAttribute : actionName + guard let val = command.actionValue?.value else { + return HandlerResponse(error: "No actionValue provided for \(actionName)") + } + + // Bridge Swift value → CFTypeRef where possible + var cf: CFTypeRef? + if let s = val as? String { cf = s as CFString } + else if let b = val as? Bool { cf = (b ? kCFBooleanTrue : kCFBooleanFalse) } + else if let n = val as? NSNumber { cf = n } + else { return HandlerResponse(error: "Unsupported value type \(type(of: val)) for \(attrName)") } + + let status = AXUIElementSetAttributeValue(raw.underlyingElement, attrName as CFString, cf!) + if status == .success { + return HandlerResponse(data: AnyCodable(PerformResponse(commandId: command.commandId, success: true))) + } else { + return HandlerResponse(error: "Failed to set \(attrName): \(axErrorToString(status))") + } + } + } + // MARK: - Helper Functions private static func finalizeAndEncodeResponse( commandId: String, commandType: String, handlerResponse: HandlerResponse, // This is from AXorcist library - debugCLI: Bool // Added debugCLI + debugCLI: Bool, // Added debugCLI + commandDebugLogging: Bool // MODIFIED: Now non-optional Bool ) async -> String { - let logsToInclude = debugCLI ? await GlobalAXLogger.shared.getLogsAsStringsIfEnabled(format: .text, includeTimestamps: false, includeLevels: false) : nil + let shouldIncludeLogs = debugCLI || commandDebugLogging + fputs("[FEAR] shouldIncludeLogs: \(shouldIncludeLogs), debugCLI: \(debugCLI), cmdDebugLogging: \(commandDebugLogging)\n", stderr) // fputs DIAGNOSTIC + axDebugLog("[finalizeAndEncodeResponse] shouldIncludeLogs: \(shouldIncludeLogs), debugCLI: \(debugCLI), cmdDebugLogging: \(commandDebugLogging)") // DIAGNOSTIC + + let logsToInclude: [String]? + if shouldIncludeLogs { + fputs("[FEAR] Attempting to fetch logs...\n", stderr) // fputs DIAGNOSTIC + axDebugLog("[finalizeAndEncodeResponse] Attempting to fetch logs...") // DIAGNOSTIC + logsToInclude = await GlobalAXLogger.shared.getLogsAsStrings( + format: .text, + includeTimestamps: false, + includeLevels: false, + includeDetails: false, + includeAppName: false, + includeCommandID: false + ) + fputs("[FEAR] Fetched logs. Count: \(logsToInclude?.count ?? -1)\n", stderr) // fputs DIAGNOSTIC + axDebugLog("[finalizeAndEncodeResponse] Fetched logs. Count: \(logsToInclude?.count ?? -1)") // DIAGNOSTIC + } else { + fputs("[FEAR] Not fetching logs.\n", stderr) // fputs DIAGNOSTIC + logsToInclude = nil + axDebugLog("[finalizeAndEncodeResponse] Not fetching logs.") // DIAGNOSTIC + } + fflush(stderr) // Ensure all fputs are flushed // Use the specialized QueryResponse initializer that takes a HandlerResponse let response = QueryResponse( @@ -461,7 +570,8 @@ struct CommandExecutor { commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: HandlerResponse(data: nil, error: errorMsg), - debugCLI: debugCLI + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging ) } @@ -520,32 +630,9 @@ struct CommandExecutor { commandId: command.commandId, commandType: command.command.rawValue, handlerResponse: HandlerResponse(data: nil, error: errorMsg), - debugCLI: debugCLI + debugCLI: debugCLI, + commandDebugLogging: command.debugLogging ) } } } - -// Extension to GlobalAXLogger for convenience -extension GlobalAXLogger { - func getLogsAsStringsIfEnabled( - format: AXLogOutputFormat, - includeTimestamps: Bool = true, - includeLevels: Bool = true, - includeDetails: Bool = false, - includeAppName: Bool = false, - includeCommandID: Bool = false - ) async -> [String]? { - if await self.isLoggingEnabled() { - return await self.getLogsAsStrings( - format: format, - includeTimestamps: includeTimestamps, - includeLevels: includeLevels, - includeDetails: includeDetails, - includeAppName: includeAppName, - includeCommandID: includeCommandID - ) - } - return nil - } -}