Major refactor
This commit is contained in:
parent
d45d5a672b
commit
dacf59720b
@ -1,95 +0,0 @@
|
||||
// AXorcist+CommandHandlers.swift - Command handler methods for AXorcist
|
||||
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
|
||||
// MARK: - Command Handlers Extension
|
||||
extension AXorcist {
|
||||
|
||||
// Placeholder for getting the focused element.
|
||||
// It should accept debug logging parameters and update logs.
|
||||
@MainActor
|
||||
public func handleGetFocusedElement(
|
||||
for appIdentifierOrNil: String? = nil,
|
||||
requestedAttributes: [String]? = nil
|
||||
) async -> HandlerResponse {
|
||||
let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey // Corrected: Use AXMiscConstants.focusedApplicationKey
|
||||
axDebugLog("[AXorcist.handleGetFocusedElement] Handling for app: \(appIdentifier)",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
|
||||
guard let appElement = applicationElement(for: appIdentifier) else {
|
||||
let errorMsgText = "Application not found: \(appIdentifier)"
|
||||
axDebugLog("[AXorcist.handleGetFocusedElement] \(errorMsgText)",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
return HandlerResponse(data: nil, error: errorMsgText)
|
||||
}
|
||||
axDebugLog("[AXorcist.handleGetFocusedElement] Successfully obtained application element for \(appIdentifier)",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
|
||||
var cfValue: CFTypeRef?
|
||||
let copyAttributeStatus = AXUIElementCopyAttributeValue(
|
||||
appElement.underlyingElement,
|
||||
AXAttributeNames.kAXFocusedUIElementAttribute as CFString,
|
||||
&cfValue
|
||||
)
|
||||
|
||||
guard copyAttributeStatus == .success, let rawAXElement = cfValue else {
|
||||
axDebugLog(
|
||||
"[AXorcist.handleGetFocusedElement] Failed to copy focused element attribute or it was nil. " +
|
||||
"Status: \(axErrorToString(copyAttributeStatus)). Application: \(appIdentifier)",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
return HandlerResponse(
|
||||
data: nil,
|
||||
error: "Could not get the focused UI element for \(appIdentifier). Ensure a window of the application is focused. AXError: \(axErrorToString(copyAttributeStatus))"
|
||||
)
|
||||
}
|
||||
|
||||
guard CFGetTypeID(rawAXElement) == AXUIElementGetTypeID() else {
|
||||
axDebugLog(
|
||||
"[AXorcist.handleGetFocusedElement] Focused element attribute was not an AXUIElement. Application: \(appIdentifier)",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
return HandlerResponse(
|
||||
data: nil,
|
||||
error: "Focused element was not a valid UI element for \(appIdentifier)."
|
||||
)
|
||||
}
|
||||
|
||||
let focusedElement = Element(rawAXElement as! AXUIElement)
|
||||
axDebugLog(
|
||||
"[AXorcist.handleGetFocusedElement] Successfully obtained focused element: " +
|
||||
"\(focusedElement.briefDescription()) for application \(appIdentifier)",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
|
||||
let (fetchedAttributes, _) = await getElementAttributes(
|
||||
element: focusedElement,
|
||||
attributes: requestedAttributes ?? [],
|
||||
outputFormat: .smart
|
||||
)
|
||||
|
||||
let elementPathArray = focusedElement.generatePathArray(upTo: appElement)
|
||||
|
||||
let axElement = AXElement(attributes: fetchedAttributes, path: elementPathArray)
|
||||
|
||||
return HandlerResponse(data: AnyCodable(axElement), error: nil)
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,214 +0,0 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
// GlobalAXLogger is expected to be available
|
||||
|
||||
// Global constant for backwards compatibility - removed, now using AXMiscConstants.defaultMaxDepthSearch
|
||||
|
||||
// Placeholder for the actual accessibility logic.
|
||||
// For now, this module is very thin and AXorcist.swift is the main public API.
|
||||
// Other files like Element.swift, Models.swift, Search.swift, etc. are in Core/ Utils/ etc.
|
||||
|
||||
public class AXorcist {
|
||||
|
||||
// let focusedAppKeyValue = "focused" // Replaced by AXMiscConstants.focusedApplicationKey
|
||||
// Removed recursiveCallDebugLogs, as GlobalAXLogger handles accumulation
|
||||
|
||||
// MARK: - Focus Tracking State (used by AXorcist+FocusTracking.swift)
|
||||
internal var focusTrackingObserver: AXObserver?
|
||||
internal var focusTrackingPID: pid_t = 0
|
||||
internal var focusTrackingCallback: AXFocusChangeCallback?
|
||||
internal var focusedUIElementToken: AXObserverCenter.SubscriptionToken?
|
||||
internal var focusedWindowToken: AXObserverCenter.SubscriptionToken?
|
||||
internal var systemWideFocusToken: AXObserverCenter.SubscriptionToken? // For system-wide tracking
|
||||
|
||||
// Default values for collection and search if not provided by the command
|
||||
// These are now primarily defined in AXMiscConstants and can be referenced from there.
|
||||
// Public static let defaultMaxDepthCollectAll = AXMiscConstants.defaultMaxDepthCollectAll
|
||||
// Public static let defaultMaxDepthSearch = AXMiscConstants.defaultMaxDepthSearch
|
||||
// Public static let defaultMaxDepthPathResolution = AXMiscConstants.defaultMaxDepthPathResolution
|
||||
// Public static let defaultMaxDepthDescribe = AXMiscConstants.defaultMaxDepthDescribe
|
||||
// Public static let defaultTimeoutPerElementCollectAll = AXMiscConstants.defaultTimeoutPerElementCollectAll
|
||||
|
||||
// Default attributes to fetch if none are specified by the command.
|
||||
// This can also be moved to AXMiscConstants if it's a shared default, or kept here if specific to AXorcist class logic.
|
||||
public static let defaultAttributesToFetch: [String] = [
|
||||
AXAttributeNames.kAXRoleAttribute,
|
||||
AXAttributeNames.kAXTitleAttribute,
|
||||
AXAttributeNames.kAXSubroleAttribute,
|
||||
AXAttributeNames.kAXIdentifierAttribute,
|
||||
AXAttributeNames.kAXDescriptionAttribute,
|
||||
AXAttributeNames.kAXValueAttribute,
|
||||
AXAttributeNames.kAXSelectedTextAttribute,
|
||||
AXAttributeNames.kAXEnabledAttribute,
|
||||
AXAttributeNames.kAXFocusedAttribute
|
||||
]
|
||||
|
||||
public init() {
|
||||
// Logging is now managed by GlobalAXLogger and per-call startCollecting/stopCollecting logic
|
||||
}
|
||||
|
||||
// Removed static func formatDebugLogMessage - GlobalAXLogger handles formatting
|
||||
|
||||
// Handler methods are implemented in extension files:
|
||||
// - handlePerformAction: AXorcist+ActionHandlers.swift
|
||||
// - handleExtractText: AXorcist+ActionHandlers.swift
|
||||
// - handleCollectAll: AXorcist+ActionHandlers.swift
|
||||
// - handleBatchCommands: AXorcist+BatchHandler.swift
|
||||
|
||||
// Handler methods are implemented in extension files:
|
||||
// - handleExtractText: AXorcist+ActionHandlers.swift
|
||||
// - handleBatchCommands: AXorcist+BatchHandler.swift
|
||||
// - handleCollectAll: AXorcist+CollectAllHandler.swift
|
||||
|
||||
// MARK: - Path Navigation
|
||||
|
||||
// MARK: - Search Operations
|
||||
|
||||
@MainActor
|
||||
public func search(
|
||||
element: Element,
|
||||
locator: Locator,
|
||||
requireAction: String? = nil,
|
||||
depth: Int = 0,
|
||||
maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch
|
||||
) async -> Element? {
|
||||
axDebugLog("AXorcist.search: Starting search from element \(element.briefDescription()). Locator: \(locator)")
|
||||
|
||||
// findElementViaCriteriaAndJSONPathHint is async
|
||||
let foundElement = await findElementViaCriteriaAndJSONPathHint(
|
||||
application: element,
|
||||
locator: locator,
|
||||
maxDepth: maxDepth
|
||||
)
|
||||
|
||||
if foundElement != nil {
|
||||
axDebugLog("AXorcist.search: findElementViaCriteriaAndJSONPathHint found an element.")
|
||||
} else {
|
||||
axDebugLog("AXorcist.search: findElementViaCriteriaAndJSONPathHint did NOT find an element.")
|
||||
}
|
||||
return foundElement
|
||||
}
|
||||
|
||||
// MARK: - Observe Command Handler
|
||||
|
||||
@MainActor
|
||||
public func handleObserve(
|
||||
for appIdentifierOrNil: String?,
|
||||
notifications: [String],
|
||||
includeElementDetails: [String],
|
||||
watchChildren: Bool,
|
||||
commandId: String,
|
||||
debugCLI: Bool
|
||||
) async -> Bool {
|
||||
let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey
|
||||
axInfoLog("[AXorcist.handleObserve][CmdID: \(commandId)] Starting observe for app: \(appIdentifier), notifications: \(notifications.joined(separator: ", ")), details: \(includeElementDetails.joined(separator: ", "))")
|
||||
// applicationElement is sync
|
||||
guard let appElement = applicationElement(for: appIdentifier) else {
|
||||
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Application not found: \(appIdentifier)")
|
||||
return false
|
||||
}
|
||||
|
||||
var subscriptionTokens: [AXObserverCenter.SubscriptionToken] = []
|
||||
|
||||
let observerCallback: AXNotificationSubscriptionHandler = {
|
||||
obsPid, notificationNameString, rawObservedElement, nsUserInfo in
|
||||
|
||||
let observedElement = Element(rawObservedElement)
|
||||
// generatePathArray is sync, pid() is sync
|
||||
let elementPath = observedElement.generatePathArray(upTo: appElement.pid() == obsPid ? appElement : nil)
|
||||
|
||||
// Since getElementAttributes is async but we're in a sync context,
|
||||
// we need to use Task to call it
|
||||
Task { @MainActor in
|
||||
let (attributes, _) = await getElementAttributes(
|
||||
element: observedElement,
|
||||
attributes: includeElementDetails,
|
||||
outputFormat: .smart
|
||||
)
|
||||
|
||||
var sanitizedElement: [String: Any] = [:]
|
||||
if !attributes.isEmpty {
|
||||
var sanitizedAttrs: [String: Any] = [:]
|
||||
for (k, v) in attributes {
|
||||
sanitizedAttrs[k] = sanitizeValue(v.value)
|
||||
}
|
||||
sanitizedElement["attributes"] = sanitizedAttrs
|
||||
}
|
||||
if !elementPath.isEmpty {
|
||||
sanitizedElement["path"] = elementPath
|
||||
}
|
||||
|
||||
let payloadRaw: [String: Any] = [
|
||||
"timestamp": Date().timeIntervalSince1970,
|
||||
"commandId": commandId,
|
||||
"notification": notificationNameString.rawValue,
|
||||
"pid": obsPid,
|
||||
"application": appIdentifier,
|
||||
"element": sanitizedElement.mapValues { $0 }
|
||||
]
|
||||
|
||||
let safePayload = makeJSONCompatible(payloadRaw) as! [String: Any]
|
||||
|
||||
if let data = try? JSONSerialization.data(withJSONObject: safePayload, options: []),
|
||||
let jsonStr = String(data: data, encoding: .utf8) {
|
||||
fputs("\(jsonStr)\n", stdout)
|
||||
fflush(stdout)
|
||||
} else {
|
||||
fputs("{\"error\": \"Unencodable payload\"}\n", stderr)
|
||||
fflush(stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var allSubscriptionsSuccessful = true
|
||||
for notificationNameString in notifications {
|
||||
guard let axNotificationName = AXNotification(rawValue: notificationNameString) else {
|
||||
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Invalid notification name string: \(notificationNameString). Skipping.")
|
||||
continue
|
||||
}
|
||||
// pid() is sync
|
||||
guard let targetPid = appElement.pid() else {
|
||||
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Could not get PID for appElement: \(appIdentifier)")
|
||||
allSubscriptionsSuccessful = false
|
||||
break
|
||||
}
|
||||
|
||||
let result = AXObserverCenter.shared.subscribe(
|
||||
pid: targetPid,
|
||||
element: appElement,
|
||||
notification: axNotificationName,
|
||||
handler: observerCallback
|
||||
)
|
||||
|
||||
switch result {
|
||||
case .success(let token):
|
||||
subscriptionTokens.append(token)
|
||||
axDebugLog("[AXorcist.handleObserve][CmdID: \(commandId)] Subscribed to \(notificationNameString) for \(appIdentifier) (PID: \(targetPid))")
|
||||
case .failure(let error):
|
||||
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Error subscribing to \(notificationNameString) for \(appIdentifier): \(error.description)")
|
||||
allSubscriptionsSuccessful = false
|
||||
break
|
||||
}
|
||||
if !allSubscriptionsSuccessful { break }
|
||||
}
|
||||
|
||||
if !allSubscriptionsSuccessful || subscriptionTokens.isEmpty {
|
||||
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Failed to subscribe to one or more notifications for \(appIdentifier). Cleaning up...")
|
||||
for token in subscriptionTokens {
|
||||
do {
|
||||
try AXObserverCenter.shared.unsubscribe(token: token)
|
||||
axDebugLog("[AXorcist.handleObserve][CmdID: \(commandId)] Unsubscribed token \(token.id) during cleanup.")
|
||||
} catch {
|
||||
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Error unsubscribing token \(token.id) during cleanup: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
axInfoLog("[AXorcist.handleObserve][CmdID: \(commandId)] Successfully subscribed to \(subscriptionTokens.count) notifications for \(appIdentifier). Streaming output to stdout.")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: The global function `findElementViaPathAndCriteria`
|
||||
145
Sources/AXorcist/Core/AXorcist+ActionHandlers.swift
Normal file
145
Sources/AXorcist/Core/AXorcist+ActionHandlers.swift
Normal file
@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import ApplicationServices
|
||||
import AppKit // For NSRunningApplication & NSValue
|
||||
|
||||
@MainActor
|
||||
extension AXorcist {
|
||||
// MARK: - Perform Action Handler
|
||||
public func handlePerformAction(command: PerformActionCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandlePerformAction: App '\(String(describing: command.appIdentifier))', Locator: \(command.locator), Action: \(command.action), Value: \(String(describing: command.value))"))
|
||||
|
||||
let (foundElement, error) = findTargetElement(
|
||||
for: command.appIdentifier ?? "focused",
|
||||
locator: command.locator,
|
||||
maxDepthForSearch: command.maxDepthForSearch
|
||||
)
|
||||
|
||||
guard let element = foundElement else {
|
||||
let errorMessage = error ?? "AXorcist/HandlePerformAction: Element not found for app '\(String(describing: command.appIdentifier))' with locator \(command.locator)."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandlePerformAction: Found element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
// Check if action is supported before attempting
|
||||
if !element.isActionSupported(command.action) {
|
||||
let errorMessage = "AXorcist/HandlePerformAction: Action '\(command.action)' is NOT supported by element \(element.briefDescription(option: ValueFormatOption.smart))."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: errorMessage))
|
||||
// Get available actions for better error reporting
|
||||
let availableActions = element.supportedActions() ?? []
|
||||
return .errorResponse(message: "\(errorMessage) Available actions: [\(availableActions.joined(separator: ", "))]", code: .actionNotSupported)
|
||||
}
|
||||
|
||||
do {
|
||||
// Note: The performAction method doesn't take a value parameter
|
||||
// If the action requires a value, it should be set separately
|
||||
if let actionValue = command.value?.value {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "AXorcist/HandlePerformAction: Action value provided but not used: \(actionValue)"))
|
||||
}
|
||||
|
||||
try element.performAction(command.action)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandlePerformAction: Successfully performed action '\(command.action)' on \(element.briefDescription(option: ValueFormatOption.smart))."))
|
||||
return .successResponse(payload: AnyCodable(["message": "Action '\(command.action)' performed successfully."]))
|
||||
} catch {
|
||||
let errorMessage = "AXorcist/HandlePerformAction: Failed to perform action '\(command.action)' on \(element.briefDescription(option: ValueFormatOption.smart)). Error: \(error)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .actionFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Set Focused Value Handler
|
||||
public func handleSetFocusedValue(command: SetFocusedValueCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleSetFocusedValue: App '\(String(describing: command.appIdentifier))', Locator: \(command.locator), Value: '\(command.value)'"))
|
||||
|
||||
let (foundElement, error) = findTargetElement(
|
||||
for: command.appIdentifier ?? "focused",
|
||||
locator: command.locator,
|
||||
maxDepthForSearch: command.maxDepthForSearch
|
||||
)
|
||||
|
||||
guard let element = foundElement else {
|
||||
let errorMessage = error ?? "AXorcist/HandleSetFocusedValue: Element not found for app '\(String(describing: command.appIdentifier))' with locator \(command.locator)."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleSetFocusedValue: Found element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
// Make sure the element is focusable, then focus it, then set its value.
|
||||
// This is a common pattern for text fields.
|
||||
|
||||
// 1. Check if focusable (kAXFocusedAttribute should be settable)
|
||||
var isFocusable = false
|
||||
// Check if the element can have the focused attribute set
|
||||
if element.isAttributeSettable(named: AXAttributeNames.kAXFocusedAttribute) {
|
||||
isFocusable = true
|
||||
}
|
||||
|
||||
if !isFocusable {
|
||||
// If not directly focusable by kAXFocusedAttribute, check if it can perform kAXPressAction, which might make it focusable.
|
||||
if element.isActionSupported(AXActionNames.kAXPressAction) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleSetFocusedValue: Element not directly focusable by kAXFocusedAttribute, but supports kAXPressAction. Attempting press."))
|
||||
do {
|
||||
try element.performAction(AXActionNames.kAXPressAction)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleSetFocusedValue: Successfully pressed element to potentially gain focus."))
|
||||
} catch {
|
||||
let pressError = "AXorcist/HandleSetFocusedValue: Element \(element.briefDescription(option: ValueFormatOption.smart)) could not be pressed to potentially gain focus."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: pressError))
|
||||
// Continue to try setting value, but log this warning.
|
||||
}
|
||||
} else {
|
||||
let focusError = "AXorcist/HandleSetFocusedValue: Element \(element.briefDescription(option: ValueFormatOption.smart)) is not focusable (kAXFocusedAttribute not settable and kAXPressAction not supported). Cannot reliably set focused value."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: focusError))
|
||||
// Proceed to set value anyway, but this is a warning.
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attempt to set focus (best effort)
|
||||
if isFocusable {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleSetFocusedValue: Attempting to set kAXFocusedAttribute to true for \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
if !element.setValue(true, forAttribute: AXAttributeNames.kAXFocusedAttribute) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "AXorcist/HandleSetFocusedValue: Failed to set kAXFocusedAttribute for \(element.briefDescription(option: ValueFormatOption.smart)), but proceeding to set value."))
|
||||
} else {
|
||||
// Short delay to allow UI to catch up after focusing, if necessary. Consider if this is needed based on app behavior.
|
||||
// Thread.sleep(forTimeInterval: 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Set the value (kAXValueAttribute)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleSetFocusedValue: Attempting to set kAXValueAttribute to '\(command.value)' for \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
if element.setValue(command.value, forAttribute: AXAttributeNames.kAXValueAttribute) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleSetFocusedValue: Successfully set value for \(element.briefDescription(option: ValueFormatOption.smart))."))
|
||||
return .successResponse(payload: AnyCodable(["message": "Value '\(command.value)' set successfully on focused element."]))
|
||||
} else {
|
||||
let setError = "AXorcist/HandleSetFocusedValue: Failed to set kAXValueAttribute for \(element.briefDescription(option: ValueFormatOption.smart))."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: setError))
|
||||
return .errorResponse(message: setError, code: .actionFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extract Text Handler
|
||||
public func handleExtractText(command: ExtractTextCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleExtractText: App '\(String(describing: command.appIdentifier))', Locator: \(command.locator), IncludeChildren: \(String(describing: command.includeChildren)), MaxDepth: \(String(describing: command.maxDepth))"))
|
||||
|
||||
let (foundElement, error) = findTargetElement(
|
||||
for: command.appIdentifier ?? "focused",
|
||||
locator: command.locator,
|
||||
maxDepthForSearch: command.maxDepthForSearch
|
||||
)
|
||||
|
||||
guard let element = foundElement else {
|
||||
let errorMessage = error ?? "AXorcist/HandleExtractText: Element not found for app '\(String(describing: command.appIdentifier))' with locator \(command.locator)."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleExtractText: Found element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
if let textContent = getElementTextualContent(element: element, includeChildren: command.includeChildren ?? true, maxDepth: command.maxDepth ?? 5) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleExtractText: Extracted text: '\(textContent)'"))
|
||||
return .successResponse(payload: AnyCodable(TextPayload(text: textContent)))
|
||||
} else {
|
||||
let message = "AXorcist/HandleExtractText: No text content found for element \(element.briefDescription(option: ValueFormatOption.smart))."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: message))
|
||||
return .successResponse(payload: AnyCodable(TextPayload(text: ""))) // Success, but no text
|
||||
}
|
||||
}
|
||||
}
|
||||
63
Sources/AXorcist/Core/AXorcist+BatchHandler.swift
Normal file
63
Sources/AXorcist/Core/AXorcist+BatchHandler.swift
Normal file
@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
extension AXorcist {
|
||||
public func handleBatchCommands(command: BatchCommandEnvelope) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleBatch: Received \(command.commands.count) sub-commands."))
|
||||
var results: [AXResponse] = []
|
||||
var overallSuccess = true
|
||||
var errorMessages: [String] = []
|
||||
|
||||
for (index, subCommandEnvelope) in command.commands.enumerated() {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleBatch: Processing sub-command \(index + 1)/\(command.commands.count): ID '\(subCommandEnvelope.commandID)', Type: \(subCommandEnvelope.command.type)"))
|
||||
|
||||
let response = processSingleBatchCommand(subCommandEnvelope.command)
|
||||
results.append(response)
|
||||
|
||||
if response.status != "success" {
|
||||
overallSuccess = false
|
||||
let errorDetail = response.error?.message ?? "Unknown error in sub-command \(subCommandEnvelope.commandID)"
|
||||
errorMessages.append("Sub-command \(subCommandEnvelope.commandID) ('\(subCommandEnvelope.command.type)') failed: \(errorDetail)")
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "AXorcist/HandleBatch: Sub-command \(subCommandEnvelope.commandID) failed: \(errorDetail)"))
|
||||
}
|
||||
}
|
||||
|
||||
if overallSuccess {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleBatch: All \(command.commands.count) sub-commands succeeded."))
|
||||
let successfulPayloads = results.map { $0.payload }
|
||||
return .successResponse(payload: AnyCodable(BatchResponsePayload(results: successfulPayloads, errors: nil)))
|
||||
} else {
|
||||
let combinedErrorMessage = "AXorcist/HandleBatch: One or more sub-commands failed. Errors: \(errorMessages.joined(separator: "; "))"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: combinedErrorMessage))
|
||||
return .errorResponse(message: combinedErrorMessage, code: .batchOperationFailed)
|
||||
}
|
||||
}
|
||||
|
||||
private func processSingleBatchCommand(_ command: AXSubCommand) -> AXResponse {
|
||||
switch command {
|
||||
case .query(let queryCommand):
|
||||
return handleQuery(command: queryCommand, maxDepth: queryCommand.maxDepthForSearch)
|
||||
case .performAction(let actionCommand):
|
||||
return handlePerformAction(command: actionCommand)
|
||||
case .getAttributes(let getAttributesCommand):
|
||||
return handleGetAttributes(command: getAttributesCommand)
|
||||
case .describeElement(let describeCommand):
|
||||
return handleDescribeElement(command: describeCommand)
|
||||
case .extractText(let extractTextCommand):
|
||||
return handleExtractText(command: extractTextCommand)
|
||||
case .setFocusedValue(let setFocusedValueCommand):
|
||||
return handleSetFocusedValue(command: setFocusedValueCommand)
|
||||
case .getElementAtPoint(let getElementAtPointCommand):
|
||||
return handleGetElementAtPoint(command: getElementAtPointCommand)
|
||||
case .getFocusedElement(let getFocusedElementCommand):
|
||||
return handleGetFocusedElement(command: getFocusedElementCommand)
|
||||
case .observe(let observeCommand):
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/BatchProc: Processing Observe command."))
|
||||
return handleObserve(command: observeCommand)
|
||||
case .collectAll(let collectAllCommand):
|
||||
return handleCollectAll(command: collectAllCommand)
|
||||
case .batch:
|
||||
return .errorResponse(message: "Nested batch commands are not supported within a single batch operation.", code: .invalidCommand)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift
Normal file
30
Sources/AXorcist/Core/AXorcist+FocusedElementHandler.swift
Normal file
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import ApplicationServices
|
||||
|
||||
@MainActor
|
||||
extension AXorcist {
|
||||
public func handleGetFocusedElement(command: GetFocusedElementCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleGetFocused: App '\(String(describing: command.appIdentifier))', Attributes: \(command.attributesToReturn?.joined(separator: ", ") ?? "default")"))
|
||||
|
||||
guard let appElement = getApplicationElement(for: command.appIdentifier ?? "focused") else {
|
||||
let errorMessage = "AXorcist/HandleGetFocused: Could not get application element for '\(String(describing: command.appIdentifier))'."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleGetFocused: Got app element: \(appElement.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
guard let focusedElement = appElement.focusedUIElement() else {
|
||||
let errorMessage = "AXorcist/HandleGetFocused: No focused element found for application '\(String(describing: command.appIdentifier))' (\(appElement.briefDescription(option: ValueFormatOption.smart))])."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: errorMessage))
|
||||
// This is not necessarily an error, could be a valid state.
|
||||
// Return success with an empty payload or specific indication.
|
||||
return .successResponse(payload: AnyCodable(NoFocusPayload(message: "No focused element found.")))
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleGetFocused: Focused element: \(focusedElement.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
let attributesToFetch = command.attributesToReturn ?? AXMiscConstants.defaultAttributesToFetch
|
||||
let elementData = buildQueryResponse(element: focusedElement, attributesToFetch: attributesToFetch, includeChildrenBrief: command.includeChildrenBrief ?? false)
|
||||
|
||||
return .successResponse(payload: AnyCodable(elementData))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import ApplicationServices // For CGPoint
|
||||
|
||||
@MainActor
|
||||
extension AXorcist {
|
||||
public func handleGetElementAtPoint(command: GetElementAtPointCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleGetElementAtPoint: App '\(command.appIdentifier ?? "focused")', Point: ([\(command.point.x), \(command.point.y)]), PID: \(command.pid ?? 0)"))
|
||||
|
||||
// Get the application element first to ensure the coordinate system context.
|
||||
// While elementAtPoint is system-wide, it's good practice to ensure app context if specified.
|
||||
guard let appElement = getApplicationElement(for: command.appIdentifier ?? "focused") else {
|
||||
let errorMessage = "AXorcist/HandleGetElementAtPoint: Could not get application element for '\(command.appIdentifier ?? "focused")'. This is needed for context, even if elementAtPoint is system-wide."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound) // Or perhaps a different error code if app context is just preferred
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleGetElementAtPoint: Context app element: \(appElement.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
let pid: pid_t = command.pid.map { pid_t($0) } ?? appElement.pid() ?? 0
|
||||
guard let elementAtPoint = Element.elementAtPoint(command.point, pid: pid) else {
|
||||
let errorMessage = "AXorcist/HandleGetElementAtPoint: No UI element found at point ([\(command.point.x), \(command.point.y)]) for app context '\(command.appIdentifier ?? "focused")'."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: errorMessage))
|
||||
// This is not necessarily an error, could be a valid state (e.g., clicked on desktop).
|
||||
// Return success with an empty payload or specific indication.
|
||||
struct NoElementAtPointPayload: Codable {
|
||||
let message: String
|
||||
let element: AXElementData?
|
||||
init(message: String, element: AXElementData? = nil) {
|
||||
self.message = message
|
||||
self.element = element
|
||||
}
|
||||
}
|
||||
return .successResponse(payload: AnyCodable(NoElementAtPointPayload(message: "No UI element found at the specified point.")))
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleGetElementAtPoint: Element at point: \(elementAtPoint.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
// Build a response with the element information
|
||||
let briefDescription = elementAtPoint.briefDescription(option: ValueFormatOption.smart)
|
||||
let role = elementAtPoint.role()
|
||||
|
||||
let elementData = AXElementData(
|
||||
briefDescription: briefDescription,
|
||||
role: role,
|
||||
attributes: [:], // Could fetch attributes if needed
|
||||
allPossibleAttributes: elementAtPoint.attributeNames(),
|
||||
textualContent: nil,
|
||||
childrenBriefDescriptions: nil,
|
||||
fullAXDescription: elementAtPoint.briefDescription(option: ValueFormatOption.stringified),
|
||||
path: elementAtPoint.path()?.components
|
||||
)
|
||||
|
||||
return .successResponse(payload: AnyCodable(elementData))
|
||||
}
|
||||
}
|
||||
52
Sources/AXorcist/Core/AXorcist+ObservationHandler.swift
Normal file
52
Sources/AXorcist/Core/AXorcist+ObservationHandler.swift
Normal file
@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import ApplicationServices
|
||||
|
||||
@MainActor
|
||||
extension AXorcist {
|
||||
public func handleObserve(command: ObserveCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleObserve: App \(command.appIdentifier ?? "focused"), Notifications: \(command.notificationName.rawValue), Details: \(command.includeElementDetails?.joined(separator: ", ") ?? "none")"))
|
||||
|
||||
let appIdentifier = command.appIdentifier ?? "focused"
|
||||
// Use Criterion for pid matching
|
||||
let criteria = [Criterion(attribute: "pid", value: "self", matchType: .exact)]
|
||||
let locator = Locator(criteria: criteria)
|
||||
|
||||
let (targetElement, error) = findTargetElement(
|
||||
for: appIdentifier,
|
||||
locator: locator,
|
||||
maxDepthForSearch: command.maxDepthForSearch
|
||||
)
|
||||
|
||||
guard let elementToObserve = targetElement else {
|
||||
let errorMessage = error ?? "AXorcist/HandleObserve: Element to observe not found for app '\(appIdentifier)' with locator \(String(describing: locator))."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleObserve: Element to observe: \(elementToObserve.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
let callback: AXObserverManager.AXNotificationCallback = { observer, axUIElement, notification, userInfo in
|
||||
let element = Element(axUIElement)
|
||||
let userInfoDesc = userInfo != nil ? String(describing: userInfo!) : "nil"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXObserver CALLBACK: Element: \(element.briefDescription(option: ValueFormatOption.smart)), Notification: \(notification as String), UserInfo: \(userInfoDesc)"))
|
||||
|
||||
// Here, you would typically send this event data back to the client that initiated the observation.
|
||||
// This might involve a registered callback URL, a WebSocket, or another IPC mechanism.
|
||||
// For now, we just log it.
|
||||
}
|
||||
|
||||
do {
|
||||
try AXObserverManager.shared.addObserver(for: elementToObserve, notification: command.notificationName, callback: callback)
|
||||
let successMessage = "AXorcist/HandleObserve: Successfully started observing '\(command.notificationName)' on \(elementToObserve.briefDescription(option: ValueFormatOption.smart))."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: successMessage))
|
||||
return .successResponse(payload: AnyCodable(["message": successMessage]))
|
||||
} catch let obError as AXObserverManager.ObserverError {
|
||||
let errorMessage = "AXorcist/HandleObserve: Failed to add observer. Error: \(obError.localizedDescription) (Code: \(obError)). Pid for element: \(elementToObserve.pid()?.description ?? "N/A") Notification: \(command.notificationName)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .observationFailed)
|
||||
} catch {
|
||||
let errorMessage = "AXorcist/HandleObserve: Failed to add observer with unknown error: \(error.localizedDescription) for element \(elementToObserve.briefDescription(option: ValueFormatOption.smart)) Notification: \(command.notificationName)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .observationFailed)
|
||||
}
|
||||
}
|
||||
}
|
||||
181
Sources/AXorcist/Core/AXorcist+QueryHandlers.swift
Normal file
181
Sources/AXorcist/Core/AXorcist+QueryHandlers.swift
Normal file
@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import ApplicationServices
|
||||
import AppKit // For NSRunningApplication
|
||||
|
||||
// Note: Assumes Element, Attribute, AXValueWrapper, etc. are defined and accessible.
|
||||
// Assumes GlobalAXLogger is available.
|
||||
|
||||
@MainActor
|
||||
extension AXorcist {
|
||||
// MARK: - Query Handler
|
||||
public func handleQuery(command: QueryCommand, maxDepth externalMaxDepth: Int?) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleQuery: App '\(command.appIdentifier ?? "focused")', Locator: \(command.locator)"))
|
||||
|
||||
let appIdentifier = command.appIdentifier ?? "focused"
|
||||
let resolvedMaxDepth = externalMaxDepth ?? 10
|
||||
|
||||
// DEBUG LOG FOR MAX DEPTH
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleQuery: externalMaxDepth = \(String(describing: externalMaxDepth)), resolved maxDepth = \(resolvedMaxDepth)"))
|
||||
|
||||
let (foundElement, findError) = findTargetElement(
|
||||
for: appIdentifier,
|
||||
locator: command.locator,
|
||||
maxDepthForSearch: resolvedMaxDepth
|
||||
)
|
||||
|
||||
guard let element = foundElement else {
|
||||
let errorMessage = findError ?? "AXorcist/HandleQuery: Element not found for app '\(appIdentifier)' with locator \(command.locator)."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleQuery: Found element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
// Fetch attributes specified in command.attributesToReturn, or default if nil/empty
|
||||
let attributesToFetch = command.attributesToReturn ?? AXMiscConstants.defaultAttributesToFetch
|
||||
let elementData = buildQueryResponse(element: element, attributesToFetch: attributesToFetch, includeChildrenBrief: command.includeChildrenBrief ?? false)
|
||||
|
||||
return .successResponse(payload: AnyCodable(elementData))
|
||||
}
|
||||
|
||||
// MARK: - Get Attributes Handler
|
||||
public func handleGetAttributes(command: GetAttributesCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleGetAttrs: App '\(command.appIdentifier ?? "focused")', Locator: \(command.locator), Attributes: \(command.attributes.joined(separator: ", "))"))
|
||||
|
||||
let (foundElement, findError) = findTargetElement(
|
||||
for: command.appIdentifier ?? "focused",
|
||||
locator: command.locator,
|
||||
maxDepthForSearch: command.maxDepthForSearch
|
||||
)
|
||||
|
||||
guard let element = foundElement else {
|
||||
let errorMessage = findError ?? "AXorcist/HandleGetAttrs: Element not found for app '\(command.appIdentifier ?? "focused")' with locator \(command.locator)."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleGetAttrs: Found element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
|
||||
var attributesDict: [String: AXValueWrapper] = [:]
|
||||
for attrName in command.attributes {
|
||||
if let value: Any = element.attribute(Attribute<Any>(attrName)) {
|
||||
attributesDict[attrName] = AXValueWrapper(value: value)
|
||||
} else {
|
||||
attributesDict[attrName] = AXValueWrapper(value: nil) // Explicitly store nil for missing attributes
|
||||
}
|
||||
}
|
||||
|
||||
let briefDesc = element.briefDescription(option: ValueFormatOption.smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleGetAttrs: Attributes for '\(briefDesc)': \(attributesDict.mapValues { String(describing: $0.anyValue?.value) })"))
|
||||
|
||||
// Log fetched attributes for debugging purposes
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/GetAttributes: Fetched attributes for \(briefDesc): \(attributesDict.mapValues { String(describing: $0.anyValue?.value) })"))
|
||||
|
||||
// Construct a simple payload containing just the attributes dictionary.
|
||||
// For a more structured response like AXElementData, we'd use buildQueryResponse or similar.
|
||||
struct AttributesPayload: Codable {
|
||||
let attributes: [String: AXValueWrapper]
|
||||
let elementDescription: String
|
||||
}
|
||||
let payload = AttributesPayload(attributes: attributesDict, elementDescription: briefDesc)
|
||||
|
||||
return .successResponse(payload: AnyCodable(payload))
|
||||
}
|
||||
|
||||
// MARK: - Describe Element Handler
|
||||
public func handleDescribeElement(command: DescribeElementCommand) -> AXResponse {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "AXorcist/HandleDescribe: App '\(command.appIdentifier ?? "focused")', Locator: \(command.locator), Depth: \(command.depth), IncludeIgnored: \(command.includeIgnored)"))
|
||||
|
||||
let (foundElement, findError) = findTargetElement(
|
||||
for: command.appIdentifier ?? "focused",
|
||||
locator: command.locator,
|
||||
maxDepthForSearch: command.maxSearchDepth
|
||||
)
|
||||
|
||||
guard let element = foundElement else {
|
||||
let errorMessage = findError ?? "AXorcist/HandleDescribe: Element not found for app '\(command.appIdentifier ?? "focused")' with locator \(command.locator)."
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: errorMessage))
|
||||
return .errorResponse(message: errorMessage, code: .elementNotFound)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "AXorcist/HandleDescribe: Found element: \(element.briefDescription(option: ValueFormatOption.smart)). Describing tree..."))
|
||||
|
||||
let descriptionTree = describeElementTree(element: element, depth: command.depth, includeIgnored: command.includeIgnored, currentDepth: 0)
|
||||
|
||||
return .successResponse(payload: AnyCodable(descriptionTree))
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods for Querying
|
||||
|
||||
internal func buildQueryResponse(element: Element, attributesToFetch: [String], includeChildrenBrief: Bool) -> AXElementData {
|
||||
let fetchedAttributes = fetchInstanceElementAttributes(element: element, attributeNames: attributesToFetch)
|
||||
|
||||
// Get all possible attribute names for this element
|
||||
let allAXAttributes = element.attributeNames()
|
||||
let textualContent = extractTextFromElement(element, maxDepth: 3) // MaxDepth set to 3 for brief text
|
||||
let childrenBriefs = includeChildrenBrief ? (element.children()?.map { $0.briefDescription(option: ValueFormatOption.smart) } ?? []) : nil
|
||||
let fullDesc = element.briefDescription(option: .stringified) // Using .stringified for a detailed description
|
||||
let pathArray = element.path()?.components // Assuming path() returns an optional Path struct with a components array
|
||||
|
||||
let briefDescription = element.briefDescription(option: ValueFormatOption.smart)
|
||||
let role = element.role()
|
||||
// let fullDescription = element.briefDescription(option: .stringified) // This is synchronous - Commented out as unused
|
||||
|
||||
return AXElementData(
|
||||
briefDescription: briefDescription,
|
||||
role: role,
|
||||
attributes: fetchedAttributes,
|
||||
allPossibleAttributes: allAXAttributes,
|
||||
textualContent: textualContent,
|
||||
childrenBriefDescriptions: childrenBriefs,
|
||||
fullAXDescription: fullDesc,
|
||||
path: pathArray
|
||||
)
|
||||
}
|
||||
|
||||
private func describeElementTree(element: Element, depth: Int, includeIgnored: Bool, currentDepth: Int) -> AXElementDescription {
|
||||
if !includeIgnored && element.isIgnored() {
|
||||
// Return a minimal description for an ignored element if not including them
|
||||
return AXElementDescription(
|
||||
briefDescription: element.briefDescription(option: ValueFormatOption.smart) + " (Ignored)",
|
||||
role: element.role(),
|
||||
attributes: [:], // No attributes for ignored elements unless explicitly asked
|
||||
children: nil
|
||||
)
|
||||
}
|
||||
|
||||
let attributes = fetchInstanceElementAttributes(element: element, attributeNames: AXMiscConstants.defaultAttributesToFetch)
|
||||
var childrenDescriptions: [AXElementDescription]? = nil
|
||||
|
||||
if currentDepth < depth {
|
||||
if let children = element.children() {
|
||||
childrenDescriptions = []
|
||||
for child in children {
|
||||
if !includeIgnored && child.isIgnored() {
|
||||
continue // Skip ignored children if not including them
|
||||
}
|
||||
childrenDescriptions?.append(describeElementTree(element: child, depth: depth, includeIgnored: includeIgnored, currentDepth: currentDepth + 1))
|
||||
}
|
||||
if childrenDescriptions?.isEmpty ?? true { childrenDescriptions = nil }
|
||||
}
|
||||
}
|
||||
|
||||
return AXElementDescription(
|
||||
briefDescription: element.briefDescription(option: ValueFormatOption.smart),
|
||||
role: element.role(),
|
||||
attributes: attributes,
|
||||
children: childrenDescriptions
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchInstanceElementAttributes(element: Element, attributeNames: [String]) -> [String: AXValueWrapper] {
|
||||
var attributesDict: [String: AXValueWrapper] = [:]
|
||||
for name in attributeNames {
|
||||
if let value: Any = element.attribute(Attribute<Any>(name)) {
|
||||
attributesDict[name] = AXValueWrapper(value: value)
|
||||
} else {
|
||||
// For attributes explicitly requested but not found, we might represent them as nil
|
||||
// or simply omit them. Current AXValueWrapper handles nils.
|
||||
attributesDict[name] = AXValueWrapper(value: nil)
|
||||
}
|
||||
}
|
||||
return attributesDict
|
||||
}
|
||||
}
|
||||
71
Sources/AXorcist/Core/AXorcist.swift
Normal file
71
Sources/AXorcist/Core/AXorcist.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import ApplicationServices
|
||||
import AppKit // For NSRunningApplication
|
||||
|
||||
// Main class for AXorcist operations
|
||||
@MainActor
|
||||
public class AXorcist {
|
||||
@MainActor public init() {}
|
||||
|
||||
public static let shared = AXorcist()
|
||||
private let logger = GlobalAXLogger.shared // Use the shared logger
|
||||
|
||||
// Central command processing function
|
||||
public func runCommand(_ commandEnvelope: AXCommandEnvelope) -> AXResponse { // Removed async
|
||||
logger.log(AXLogEntry(level: .info, message: "AXorcist/RunCommand: ID '\(commandEnvelope.commandID)', Type: \(commandEnvelope.command.type)")) // Removed await
|
||||
|
||||
let response: AXResponse
|
||||
switch commandEnvelope.command {
|
||||
case .query(let queryCommand):
|
||||
response = handleQuery(command: queryCommand, maxDepth: queryCommand.maxDepthForSearch)
|
||||
case .performAction(let actionCommand):
|
||||
response = handlePerformAction(command: actionCommand) // Removed await
|
||||
case .getAttributes(let getAttributesCommand):
|
||||
response = handleGetAttributes(command: getAttributesCommand) // Removed await
|
||||
case .describeElement(let describeCommand):
|
||||
response = handleDescribeElement(command: describeCommand) // Removed await
|
||||
case .extractText(let extractTextCommand):
|
||||
response = handleExtractText(command: extractTextCommand) // Removed await
|
||||
case .batch(let batchCommandEnvelope):
|
||||
// The batch command itself is an envelope, pass it directly to handleBatchCommands.
|
||||
response = handleBatchCommands(command: batchCommandEnvelope) // Removed await
|
||||
case .setFocusedValue(let setFocusedValueCommand):
|
||||
response = handleSetFocusedValue(command: setFocusedValueCommand) // Removed await
|
||||
case .getElementAtPoint(let getElementAtPointCommand):
|
||||
response = handleGetElementAtPoint(command: getElementAtPointCommand) // Removed await
|
||||
case .getFocusedElement(let getFocusedElementCommand):
|
||||
response = handleGetFocusedElement(command: getFocusedElementCommand) // Removed await
|
||||
case .observe(let observeCommand):
|
||||
response = handleObserve(command: observeCommand) // Removed await
|
||||
case .collectAll(let collectAllCommand):
|
||||
response = handleCollectAll(command: collectAllCommand)
|
||||
// Add other command types here
|
||||
// default:
|
||||
// let errormsg = "AXorcist/RunCommand: Unknown command type: \(commandEnvelope.command.type)"
|
||||
// logger.log(AXLogEntry(level: .error, message: errormsg))
|
||||
// response = .errorResponse(message: errormsg, code: .unknownCommand)
|
||||
}
|
||||
|
||||
logger.log(AXLogEntry(level: .info, message: "AXorcist/RunCommand ID '\(commandEnvelope.commandID)' completed. Status: \(response.status)")) // Removed await
|
||||
return response
|
||||
}
|
||||
|
||||
// MARK: - CollectAll Handler (New)
|
||||
internal func handleCollectAll(command: CollectAllCommand) -> AXResponse {
|
||||
// Placeholder implementation - replace with actual logic
|
||||
logger.log(AXLogEntry(level: .info, message: "AXorcist/HandleCollectAll: Command received for app '\(command.appIdentifier ?? "nil")'. Not yet fully implemented."))
|
||||
// TODO: Implement actual collect all logic using command.appIdentifier, command.attributesToReturn, command.maxDepth, command.filterCriteria, command.valueFormatOption
|
||||
return .errorResponse(message: "CollectAll command not yet fully implemented.", code: .unknownCommand)
|
||||
}
|
||||
|
||||
// MARK: - Logger Methods
|
||||
|
||||
public func getLogs() -> [String] {
|
||||
return GlobalAXLogger.shared.getLogsAsStrings()
|
||||
}
|
||||
|
||||
public func clearLogs() {
|
||||
GlobalAXLogger.shared.clearEntries()
|
||||
logger.log(AXLogEntry(level: .info, message: "AXorcist log history cleared."))
|
||||
}
|
||||
}
|
||||
@ -286,7 +286,7 @@ public enum AXRoleNames {
|
||||
}
|
||||
|
||||
// MARK: - Accessibility Notification Names (Moved from AXNotificationConstants.swift)
|
||||
public enum AXNotification: String {
|
||||
public enum AXNotification: String, Sendable {
|
||||
// System-Wide Notifications
|
||||
case mainWindowChanged = "AXMainWindowChanged" // kAXMainWindowChangedNotification
|
||||
case focusedWindowChanged = "AXFocusedWindowChanged" // kAXFocusedWindowChangedNotification
|
||||
@ -341,6 +341,21 @@ public enum AXNotification: String {
|
||||
public enum AXMiscConstants {
|
||||
public static let axBinaryVersion = "0.8.0" // AXorcist version for this constants file
|
||||
|
||||
// Default attributes to fetch when none are specified
|
||||
public static let defaultAttributesToFetch: [String] = [
|
||||
AXAttributeNames.kAXRoleAttribute,
|
||||
AXAttributeNames.kAXSubroleAttribute,
|
||||
AXAttributeNames.kAXTitleAttribute,
|
||||
AXAttributeNames.kAXValueAttribute,
|
||||
AXAttributeNames.kAXIdentifierAttribute,
|
||||
AXAttributeNames.kAXDescriptionAttribute,
|
||||
AXAttributeNames.kAXEnabledAttribute,
|
||||
AXAttributeNames.kAXFocusedAttribute,
|
||||
AXAttributeNames.kAXPositionAttribute,
|
||||
AXAttributeNames.kAXSizeAttribute,
|
||||
AXAttributeNames.kAXChildrenAttribute // To get an idea of hierarchy
|
||||
]
|
||||
|
||||
// Default values for collection and search
|
||||
public static let defaultMaxDepthCollectAll = 5
|
||||
public static let defaultMaxDepthSearch = 10
|
||||
|
||||
@ -1,161 +0,0 @@
|
||||
import Foundation
|
||||
import CoreGraphics // Import for CGPoint, CGSize, CGRect
|
||||
import Accessibility // Import for AXTextMarker, AXTextMarkerRange
|
||||
import ApplicationServices // For AXUIElement
|
||||
// It's likely AXorcist module, where this file lives, already imports Accessibility or AppKit,
|
||||
// which would make AXTextMarker and AXTextMarkerRange available.
|
||||
// If not, a more dynamic type check might be needed, or this file needs to import them.
|
||||
|
||||
// For encoding/decoding 'Any' type in JSON, especially for element attributes.
|
||||
// Note: @unchecked Sendable is used because 'Any' cannot guarantee thread safety
|
||||
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
public let value: Any
|
||||
|
||||
public init<T>(_ value: T?) {
|
||||
self.value = value ?? ()
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if let decodedValue = try AnyCodable.decodeValue(from: container) {
|
||||
self.value = decodedValue
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container,
|
||||
debugDescription: "AnyCodable value cannot be decoded"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
if try encodeValue(value, to: &container) {
|
||||
return
|
||||
}
|
||||
|
||||
let context = EncodingError.Context(
|
||||
codingPath: container.codingPath,
|
||||
debugDescription: "AnyCodable value cannot be encoded"
|
||||
)
|
||||
throw EncodingError.invalidValue(value, context)
|
||||
}
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
|
||||
private static func decodeValue(from container: SingleValueDecodingContainer) throws -> Any? {
|
||||
if container.decodeNil() {
|
||||
return ()
|
||||
}
|
||||
|
||||
// Try primitive types
|
||||
if let primitiveValue = try decodePrimitiveValue(from: container) {
|
||||
return primitiveValue
|
||||
}
|
||||
|
||||
// Try collection types
|
||||
if let collectionValue = try decodeCollectionValue(from: container) {
|
||||
return collectionValue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodePrimitiveValue(from container: SingleValueDecodingContainer) throws -> Any? {
|
||||
if let bool = try? container.decode(Bool.self) { return bool }
|
||||
if let int = try? container.decode(Int.self) { return int }
|
||||
if let int32 = try? container.decode(Int32.self) { return int32 }
|
||||
if let int64 = try? container.decode(Int64.self) { return int64 }
|
||||
if let uint = try? container.decode(UInt.self) { return uint }
|
||||
if let uint32 = try? container.decode(UInt32.self) { return uint32 }
|
||||
if let uint64 = try? container.decode(UInt64.self) { return uint64 }
|
||||
if let double = try? container.decode(Double.self) { return double }
|
||||
if let float = try? container.decode(Float.self) { return float }
|
||||
if let string = try? container.decode(String.self) { return string }
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeCollectionValue(from container: SingleValueDecodingContainer) throws -> Any? {
|
||||
if let array = try? container.decode([AnyCodable].self) {
|
||||
return array.map { $0.value }
|
||||
}
|
||||
if let dictionary = try? container.decode([String: AnyCodable].self) {
|
||||
return dictionary.mapValues { $0.value }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func encodeValue(_ value: Any, to container: inout SingleValueEncodingContainer) throws -> Bool {
|
||||
switch value {
|
||||
case is Void:
|
||||
try container.encodeNil()
|
||||
return true
|
||||
case let primitiveValue:
|
||||
return try encodePrimitiveValue(primitiveValue, to: &container)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodePrimitiveValue(_ value: Any, to container: inout SingleValueEncodingContainer) throws -> Bool {
|
||||
switch value {
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let int32 as Int32:
|
||||
try container.encode(Int(int32))
|
||||
case let int64 as Int64:
|
||||
try container.encode(int64)
|
||||
case let uint as UInt:
|
||||
try container.encode(uint)
|
||||
case let uint32 as UInt32:
|
||||
try container.encode(uint32)
|
||||
case let uint64 as UInt64:
|
||||
try container.encode(uint64)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let float as Float:
|
||||
try container.encode(float)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let point as CGPoint:
|
||||
try container.encode(["x": point.x, "y": point.y])
|
||||
case let size as CGSize:
|
||||
try container.encode(["width": size.width, "height": size.height])
|
||||
case let url as NSURL:
|
||||
try container.encode(url.absoluteString)
|
||||
case let rect as CGRect:
|
||||
try container.encode(["x": rect.origin.x, "y": rect.origin.y, "width": rect.size.width, "height": rect.size.height])
|
||||
case let notif as AXNotification:
|
||||
// AXorcist: Handle AXNotification by encoding its raw string value.
|
||||
try container.encode(notif.rawValue)
|
||||
case let attrStr as NSAttributedString:
|
||||
// AXorcist: Handle NSAttributedString by encoding its string content.
|
||||
try container.encode(attrStr.string)
|
||||
case let element as Element:
|
||||
// AXorcist: Handle AXorcist 'Element' type by encoding its string description as a fallback.
|
||||
// Prefer specific serialization if a structured JSON representation is needed.
|
||||
try container.encode(String(describing: element))
|
||||
case let axEl as AXUIElement:
|
||||
// AXorcist: Handle CoreFoundation AXUIElement by encoding its string description as a fallback.
|
||||
// This avoids direct encoding of an opaque CFType.
|
||||
try container.encode(String(describing: axEl))
|
||||
case let val where String(describing: type(of: val)) == "__NSCFType":
|
||||
// Fallback for other __NSCFType instances (CoreFoundation types not explicitly handled).
|
||||
try container.encode(String(describing: val))
|
||||
case let array as [AnyCodable]:
|
||||
try container.encode(array)
|
||||
case let array as [Any?]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dictionary as [String: AnyCodable]:
|
||||
try container.encode(dictionary)
|
||||
case let dictionary as [String: Any?]:
|
||||
try container.encode(dictionary.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
// DEBUG: Print the type of unhandled values
|
||||
print("AnyCodable unhandled type: \(String(describing: type(of: value))), value: \(String(describing: value))")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,14 @@
|
||||
import CoreGraphics // For CGPoint
|
||||
import Foundation
|
||||
|
||||
// Enum for specifying how values, especially for descriptions, should be formatted.
|
||||
public enum ValueFormatOption: String, Codable, Sendable {
|
||||
case smart // Tries to provide the most useful, possibly summarized, representation.
|
||||
case raw // Provides the raw or complete value, potentially verbose.
|
||||
case textContent // Specifically for text content extraction, might ignore non-textual parts.
|
||||
case stringified // For detailed string representation, often for logging or debugging.
|
||||
}
|
||||
|
||||
// Main command envelope - REPLACED with definition from axorc.swift for consistency
|
||||
public struct CommandEnvelope: Codable {
|
||||
public let commandId: String
|
||||
@ -12,7 +20,7 @@ public struct CommandEnvelope: Codable {
|
||||
public let payload: [String: String]? // For ping compatibility
|
||||
public let debugLogging: Bool
|
||||
public let locator: Locator? // Locator from this file
|
||||
public let pathHint: [String]?
|
||||
public let pathHint: [String]? // This is likely legacy, Locator.rootElementPathHint is preferred
|
||||
public let maxElements: Int?
|
||||
public let maxDepth: Int?
|
||||
public let outputFormat: OutputFormat? // OutputFormat from this file
|
||||
@ -29,6 +37,11 @@ public struct CommandEnvelope: Codable {
|
||||
|
||||
// New field for collectAll filtering
|
||||
public let filterCriteria: [String: String]?
|
||||
|
||||
// Additional fields for various commands
|
||||
public let includeChildrenBrief: Bool?
|
||||
public let includeChildrenInText: Bool?
|
||||
public let includeIgnoredElements: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case commandId
|
||||
@ -53,9 +66,12 @@ public struct CommandEnvelope: Codable {
|
||||
case watchChildren
|
||||
// CodingKey for new field
|
||||
case filterCriteria
|
||||
// Additional CodingKeys
|
||||
case includeChildrenBrief
|
||||
case includeChildrenInText
|
||||
case includeIgnoredElements
|
||||
}
|
||||
|
||||
// Added a public initializer for convenience, matching fields.
|
||||
public init(commandId: String,
|
||||
command: CommandType,
|
||||
application: String? = nil,
|
||||
@ -72,12 +88,13 @@ public struct CommandEnvelope: Codable {
|
||||
subCommands: [CommandEnvelope]? = nil,
|
||||
point: CGPoint? = nil,
|
||||
pid: Int? = nil,
|
||||
// Init parameters for observe
|
||||
notifications: [String]? = nil,
|
||||
includeElementDetails: [String]? = nil,
|
||||
watchChildren: Bool? = nil,
|
||||
// Init parameter for new field
|
||||
filterCriteria: [String: String]? = nil
|
||||
filterCriteria: [String: String]? = nil,
|
||||
includeChildrenBrief: Bool? = nil,
|
||||
includeChildrenInText: Bool? = nil,
|
||||
includeIgnoredElements: Bool? = nil
|
||||
) {
|
||||
self.commandId = commandId
|
||||
self.command = command
|
||||
@ -95,47 +112,137 @@ public struct CommandEnvelope: Codable {
|
||||
self.subCommands = subCommands
|
||||
self.point = point
|
||||
self.pid = pid
|
||||
// Assignments for observe parameters
|
||||
self.notifications = notifications
|
||||
self.includeElementDetails = includeElementDetails
|
||||
self.watchChildren = watchChildren
|
||||
// Assignment for new field
|
||||
self.filterCriteria = filterCriteria
|
||||
self.includeChildrenBrief = includeChildrenBrief
|
||||
self.includeChildrenInText = includeChildrenInText
|
||||
self.includeIgnoredElements = includeIgnoredElements
|
||||
}
|
||||
}
|
||||
|
||||
// Represents a single criterion for element matching
|
||||
public struct Criterion: Codable {
|
||||
public struct Criterion: Codable, Sendable {
|
||||
public let attribute: String
|
||||
public let value: String
|
||||
public let match_type: String? // Match type can be optional, defaulting to exact if nil
|
||||
public let match_type: JSONPathHintComponent.MatchType? // Retained for flexibility if needed directly in Criterion
|
||||
public let matchType: JSONPathHintComponent.MatchType? // Preferred name, aliased in custom init/codingkeys if needed
|
||||
|
||||
// To handle decoding from either "match_type" or "matchType"
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case attribute, value
|
||||
case match_type // for decoding json
|
||||
case matchType // for swift code
|
||||
}
|
||||
|
||||
public init(attribute: String, value: String, matchType: JSONPathHintComponent.MatchType? = nil) {
|
||||
self.attribute = attribute
|
||||
self.value = value
|
||||
self.match_type = matchType // Set both to ensure consistency during encoding if old key is used
|
||||
self.matchType = matchType
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
attribute = try container.decode(String.self, forKey: .attribute)
|
||||
value = try container.decode(String.self, forKey: .value)
|
||||
// Try decoding 'matchType' first, then fall back to 'match_type'
|
||||
if let mt = try container.decodeIfPresent(JSONPathHintComponent.MatchType.self, forKey: .matchType) {
|
||||
matchType = mt
|
||||
match_type = mt
|
||||
} else if let mtOld = try container.decodeIfPresent(JSONPathHintComponent.MatchType.self, forKey: .match_type) {
|
||||
matchType = mtOld
|
||||
match_type = mtOld
|
||||
} else {
|
||||
matchType = nil
|
||||
match_type = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(attribute, forKey: .attribute)
|
||||
try container.encode(value, forKey: .value)
|
||||
// Encode using the preferred 'matchType' key
|
||||
try container.encodeIfPresent(matchType, forKey: .matchType)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a step in a hierarchical path, defined by a set of criteria.
|
||||
public struct PathStep: Codable, Sendable {
|
||||
public let criteria: [Criterion]
|
||||
public let matchType: JSONPathHintComponent.MatchType? // How to evaluate criteria (e.g., exact, contains)
|
||||
public let matchAllCriteria: Bool? // Whether all criteria must match (AND) or any (OR)
|
||||
public let maxDepthForStep: Int? // Maximum depth to search for this specific step
|
||||
|
||||
// CodingKeys to map JSON keys to Swift properties
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case criteria
|
||||
case matchType
|
||||
case matchAllCriteria
|
||||
case maxDepthForStep = "max_depth_for_step" // Map JSON's snake_case to Swift's camelCase
|
||||
}
|
||||
|
||||
// Default initializer
|
||||
public init(criteria: [Criterion],
|
||||
matchType: JSONPathHintComponent.MatchType? = .exact,
|
||||
matchAllCriteria: Bool? = true,
|
||||
maxDepthForStep: Int? = nil) { // Added maxDepthForStep
|
||||
self.criteria = criteria
|
||||
self.matchType = matchType
|
||||
self.matchAllCriteria = matchAllCriteria
|
||||
self.maxDepthForStep = maxDepthForStep // Initialize
|
||||
}
|
||||
|
||||
/// Returns a string representation suitable for logging
|
||||
public func descriptionForLog() -> String {
|
||||
let critDesc = criteria.map { criterion -> String in
|
||||
"\(criterion.attribute):\(criterion.value)(\((criterion.matchType ?? .exact).rawValue))"
|
||||
}.joined(separator: ", ")
|
||||
|
||||
let depthStringPart: String
|
||||
if let depth = maxDepthForStep {
|
||||
depthStringPart = ", Depth: \(depth)"
|
||||
} else {
|
||||
depthStringPart = ""
|
||||
}
|
||||
|
||||
let matchTypeStringPart = (matchType ?? .exact).rawValue
|
||||
let matchAllStringPart = "\(matchAllCriteria ?? true)"
|
||||
|
||||
return "[Criteria: (\(critDesc)), MatchType: \(matchTypeStringPart), MatchAll: \(matchAllStringPart)\(depthStringPart)]"
|
||||
}
|
||||
}
|
||||
|
||||
// Locator for finding elements
|
||||
public struct Locator: Codable {
|
||||
public var matchAll: Bool?
|
||||
public var criteria: [Criterion] // Changed from [String: String] to [Criterion]
|
||||
public var rootElementPathHint: [JSONPathHintComponent]?
|
||||
public var descendantCriteria: [String: String]?
|
||||
public struct Locator: Codable, Sendable {
|
||||
public var matchAll: Bool? // For the top-level criteria, if path_from_root is not used or fails early.
|
||||
public var criteria: [Criterion]
|
||||
public var rootElementPathHint: [JSONPathHintComponent]? // Changed from [PathStep]?
|
||||
public var descendantCriteria: [String: String]? // This seems to be an older/alternative way? Consider phasing out or clarifying.
|
||||
public var requireAction: String?
|
||||
public var computedNameContains: String?
|
||||
public var debugPathSearch: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case matchAll
|
||||
case criteria
|
||||
case rootElementPathHint
|
||||
case rootElementPathHint = "path_from_root" // Map to JSON key "path_from_root"
|
||||
case descendantCriteria
|
||||
case requireAction
|
||||
case computedNameContains
|
||||
case debugPathSearch
|
||||
}
|
||||
|
||||
public init(
|
||||
matchAll: Bool? = nil,
|
||||
criteria: [Criterion] = [], // Adjusted default value
|
||||
rootElementPathHint: [JSONPathHintComponent]? = nil,
|
||||
matchAll: Bool? = true, // Default to true for criteria
|
||||
criteria: [Criterion] = [],
|
||||
rootElementPathHint: [JSONPathHintComponent]? = nil, // Changed from [PathStep]?
|
||||
descendantCriteria: [String: String]? = nil,
|
||||
requireAction: String? = nil,
|
||||
computedNameContains: String? = nil
|
||||
computedNameContains: String? = nil,
|
||||
debugPathSearch: Bool? = false
|
||||
) {
|
||||
self.matchAll = matchAll
|
||||
self.criteria = criteria
|
||||
@ -143,5 +250,411 @@ public struct Locator: Codable {
|
||||
self.descendantCriteria = descendantCriteria
|
||||
self.requireAction = requireAction
|
||||
self.computedNameContains = computedNameContains
|
||||
self.debugPathSearch = debugPathSearch
|
||||
}
|
||||
}
|
||||
|
||||
public enum CommandType: String, Codable, Sendable {
|
||||
case ping
|
||||
case query
|
||||
case getAttributes
|
||||
case describeElement
|
||||
case getElementAtPoint
|
||||
case getFocusedElement
|
||||
case performAction
|
||||
case batch
|
||||
case observe
|
||||
case collectAll
|
||||
case stopObservation
|
||||
case isProcessTrusted
|
||||
case isAXFeatureEnabled
|
||||
case setFocusedValue // Added from error
|
||||
case extractText // Added from error
|
||||
case setNotificationHandler // For AXObserver
|
||||
case removeNotificationHandler // For AXObserver
|
||||
case getElementDescription // Utility command for full description
|
||||
}
|
||||
|
||||
public enum OutputFormat: String, Codable, Sendable {
|
||||
case json
|
||||
case verbose
|
||||
case smart // Default, tries to be concise and informative
|
||||
case jsonString // JSON output as a string, often for AXpector.
|
||||
case textContent // Specifically for text content output, might ignore non-textual parts.
|
||||
}
|
||||
|
||||
// MARK: - AnyCodable for mixed-type payloads or attributes
|
||||
|
||||
// Reverted to simpler AnyCodable with public 'value' to match widespread usage
|
||||
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
public let value: Any
|
||||
|
||||
public init<T>(_ value: T?) {
|
||||
self.value = value ?? ()
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self.value = ()
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
self.value = bool
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
self.value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
self.value = double
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
self.value = string
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
self.value = array.map { $0.value }
|
||||
} else if let dictionary = try? container.decode([String: AnyCodable].self) {
|
||||
self.value = dictionary.mapValues { $0.value }
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded")
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
if value is () { // Our nil marker for explicit nil
|
||||
try container.encodeNil()
|
||||
return
|
||||
}
|
||||
switch value {
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dictionary as [String: Any]:
|
||||
try container.encode(dictionary.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
if let codableValue = value as? Encodable {
|
||||
// If the value conforms to Encodable, let it encode itself using the provided encoder.
|
||||
// This is the most flexible approach as the Encodable type can use any container type it needs.
|
||||
try codableValue.encode(to: encoder)
|
||||
} else if CFGetTypeID(value as CFTypeRef) == CFNullGetTypeID() {
|
||||
try container.encodeNil()
|
||||
} else {
|
||||
throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: [], debugDescription: "AnyCodable value (\(type(of: value))) cannot be encoded and does not conform to Encodable."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper struct for AnyCodable to properly encode intermediate Encodable values
|
||||
// This might not be necessary if the direct (value as! Encodable).encode(to: encoder) works.
|
||||
struct AnyCodablePośrednik<T: Encodable>: Encodable {
|
||||
let value: T
|
||||
init(_ value: T) { self.value = value }
|
||||
func encode(to encoder: Encoder) throws {
|
||||
try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper protocol to check if a type is Optional
|
||||
fileprivate protocol OptionalProtocol {
|
||||
static func isOptional() -> Bool
|
||||
}
|
||||
|
||||
extension Optional: OptionalProtocol {
|
||||
static func isOptional() -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AXNotificationName enum
|
||||
// Define AXNotificationName as a String-based enum for notification names
|
||||
public enum AXNotificationName: String, Codable, Sendable {
|
||||
case focusedUIElementChanged = "AXFocusedUIElementChanged"
|
||||
case valueChanged = "AXValueChanged"
|
||||
case uiElementDestroyed = "AXUIElementDestroyed"
|
||||
case mainWindowChanged = "AXMainWindowChanged"
|
||||
case focusedWindowChanged = "AXFocusedWindowChanged"
|
||||
case applicationActivated = "AXApplicationActivated"
|
||||
case applicationDeactivated = "AXApplicationDeactivated"
|
||||
case applicationHidden = "AXApplicationHidden"
|
||||
case applicationShown = "AXApplicationShown"
|
||||
case windowCreated = "AXWindowCreated"
|
||||
case windowResized = "AXWindowResized"
|
||||
case windowMoved = "AXWindowMoved"
|
||||
case announcementRequested = "AXAnnouncementRequested"
|
||||
case focusedApplicationChanged = "AXFocusedApplicationChanged"
|
||||
case focusedTabChanged = "AXFocusedTabChanged"
|
||||
case windowMinimized = "AXWindowMiniaturized"
|
||||
case windowDeminiaturized = "AXWindowDeminiaturized"
|
||||
case sheetCreated = "AXSheetCreated"
|
||||
case drawerCreated = "AXDrawerCreated"
|
||||
case titleChanged = "AXTitleChanged"
|
||||
case resized = "AXResized"
|
||||
case moved = "AXMoved"
|
||||
case created = "AXCreated"
|
||||
case layoutChanged = "AXLayoutChanged"
|
||||
case selectedTextChanged = "AXSelectedTextChanged"
|
||||
case rowCountChanged = "AXRowCountChanged"
|
||||
case selectedChildrenChanged = "AXSelectedChildrenChanged"
|
||||
case selectedRowsChanged = "AXSelectedRowsChanged"
|
||||
case selectedColumnsChanged = "AXSelectedColumnsChanged"
|
||||
case rowExpanded = "AXRowExpanded"
|
||||
case rowCollapsed = "AXRowCollapsed"
|
||||
case selectedCellsChanged = "AXSelectedCellsChanged"
|
||||
case helpTagCreated = "AXHelpTagCreated"
|
||||
case loadComplete = "AXLoadComplete"
|
||||
}
|
||||
|
||||
// MARK: - AXCommand and Command Structs
|
||||
|
||||
// Enum representing all possible AX commands
|
||||
public enum AXCommand: Sendable {
|
||||
case query(QueryCommand)
|
||||
case performAction(PerformActionCommand)
|
||||
case getAttributes(GetAttributesCommand)
|
||||
case describeElement(DescribeElementCommand)
|
||||
case extractText(ExtractTextCommand)
|
||||
case batch(AXBatchCommand)
|
||||
case setFocusedValue(SetFocusedValueCommand)
|
||||
case getElementAtPoint(GetElementAtPointCommand)
|
||||
case getFocusedElement(GetFocusedElementCommand)
|
||||
case observe(ObserveCommand)
|
||||
case collectAll(CollectAllCommand)
|
||||
|
||||
// Computed property to get command type
|
||||
public var type: String {
|
||||
switch self {
|
||||
case .query: return "query"
|
||||
case .performAction: return "performAction"
|
||||
case .getAttributes: return "getAttributes"
|
||||
case .describeElement: return "describeElement"
|
||||
case .extractText: return "extractText"
|
||||
case .batch: return "batch"
|
||||
case .setFocusedValue: return "setFocusedValue"
|
||||
case .getElementAtPoint: return "getElementAtPoint"
|
||||
case .getFocusedElement: return "getFocusedElement"
|
||||
case .observe: return "observe"
|
||||
case .collectAll: return "collectAll"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Command envelope for AXorcist
|
||||
public struct AXCommandEnvelope: Sendable {
|
||||
public let commandID: String
|
||||
public let command: AXCommand
|
||||
|
||||
public init(commandID: String, command: AXCommand) {
|
||||
self.commandID = commandID
|
||||
self.command = command
|
||||
}
|
||||
}
|
||||
|
||||
// Individual command structs
|
||||
public struct QueryCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let locator: Locator
|
||||
public let attributesToReturn: [String]?
|
||||
public let maxDepthForSearch: Int
|
||||
public let includeChildrenBrief: Bool?
|
||||
|
||||
public init(appIdentifier: String?, locator: Locator, attributesToReturn: [String]? = nil, maxDepthForSearch: Int = 10, includeChildrenBrief: Bool? = nil) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.locator = locator
|
||||
self.attributesToReturn = attributesToReturn
|
||||
self.maxDepthForSearch = maxDepthForSearch
|
||||
self.includeChildrenBrief = includeChildrenBrief
|
||||
}
|
||||
}
|
||||
|
||||
public struct PerformActionCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let locator: Locator
|
||||
public let action: String
|
||||
public let value: AnyCodable?
|
||||
public let maxDepthForSearch: Int
|
||||
|
||||
public init(appIdentifier: String?, locator: Locator, action: String, value: AnyCodable? = nil, maxDepthForSearch: Int = 10) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.locator = locator
|
||||
self.action = action
|
||||
self.value = value
|
||||
self.maxDepthForSearch = maxDepthForSearch
|
||||
}
|
||||
}
|
||||
|
||||
public struct GetAttributesCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let locator: Locator
|
||||
public let attributes: [String]
|
||||
public let maxDepthForSearch: Int
|
||||
|
||||
public init(appIdentifier: String?, locator: Locator, attributes: [String], maxDepthForSearch: Int = 10) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.locator = locator
|
||||
self.attributes = attributes
|
||||
self.maxDepthForSearch = maxDepthForSearch
|
||||
}
|
||||
}
|
||||
|
||||
public struct DescribeElementCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let locator: Locator
|
||||
public let formatOption: ValueFormatOption
|
||||
public let maxDepthForSearch: Int
|
||||
public let depth: Int
|
||||
public let includeIgnored: Bool
|
||||
public let maxSearchDepth: Int
|
||||
|
||||
public init(appIdentifier: String?, locator: Locator, formatOption: ValueFormatOption = .smart, maxDepthForSearch: Int = 10, depth: Int = 3, includeIgnored: Bool = false, maxSearchDepth: Int = 10) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.locator = locator
|
||||
self.formatOption = formatOption
|
||||
self.maxDepthForSearch = maxDepthForSearch
|
||||
self.depth = depth
|
||||
self.includeIgnored = includeIgnored
|
||||
self.maxSearchDepth = maxSearchDepth
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExtractTextCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let locator: Locator
|
||||
public let maxDepthForSearch: Int
|
||||
public let includeChildren: Bool?
|
||||
public let maxDepth: Int?
|
||||
|
||||
public init(appIdentifier: String?, locator: Locator, maxDepthForSearch: Int = 10, includeChildren: Bool? = nil, maxDepth: Int? = nil) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.locator = locator
|
||||
self.maxDepthForSearch = maxDepthForSearch
|
||||
self.includeChildren = includeChildren
|
||||
self.maxDepth = maxDepth
|
||||
}
|
||||
}
|
||||
|
||||
public struct SetFocusedValueCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let locator: Locator
|
||||
public let value: String
|
||||
public let maxDepthForSearch: Int
|
||||
|
||||
public init(appIdentifier: String?, locator: Locator, value: String, maxDepthForSearch: Int = 10) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.locator = locator
|
||||
self.value = value
|
||||
self.maxDepthForSearch = maxDepthForSearch
|
||||
}
|
||||
}
|
||||
|
||||
public struct GetElementAtPointCommand: Sendable {
|
||||
public let point: CGPoint
|
||||
public let appIdentifier: String?
|
||||
public let pid: Int?
|
||||
public let x: Float
|
||||
public let y: Float
|
||||
public let attributesToReturn: [String]?
|
||||
public let includeChildrenBrief: Bool?
|
||||
|
||||
public init(point: CGPoint, appIdentifier: String? = nil, pid: Int? = nil, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) {
|
||||
self.point = point
|
||||
self.appIdentifier = appIdentifier
|
||||
self.pid = pid
|
||||
self.x = Float(point.x)
|
||||
self.y = Float(point.y)
|
||||
self.attributesToReturn = attributesToReturn
|
||||
self.includeChildrenBrief = includeChildrenBrief
|
||||
}
|
||||
|
||||
public init(appIdentifier: String?, x: Float, y: Float, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) {
|
||||
self.point = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
||||
self.appIdentifier = appIdentifier
|
||||
self.pid = nil
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.attributesToReturn = attributesToReturn
|
||||
self.includeChildrenBrief = includeChildrenBrief
|
||||
}
|
||||
}
|
||||
|
||||
public struct GetFocusedElementCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let attributesToReturn: [String]?
|
||||
public let includeChildrenBrief: Bool?
|
||||
|
||||
public init(appIdentifier: String?, attributesToReturn: [String]? = nil, includeChildrenBrief: Bool? = nil) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.attributesToReturn = attributesToReturn
|
||||
self.includeChildrenBrief = includeChildrenBrief
|
||||
}
|
||||
}
|
||||
|
||||
public struct ObserveCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let locator: Locator?
|
||||
public let notifications: [String]
|
||||
public let includeDetails: Bool
|
||||
public let watchChildren: Bool
|
||||
public let notificationName: AXNotification
|
||||
public let includeElementDetails: [String]?
|
||||
public let maxDepthForSearch: Int
|
||||
|
||||
public init(appIdentifier: String?, locator: Locator? = nil, notifications: [String], includeDetails: Bool = true, watchChildren: Bool = false, notificationName: AXNotification, includeElementDetails: [String]? = nil, maxDepthForSearch: Int = 10) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.locator = locator
|
||||
self.notifications = notifications
|
||||
self.includeDetails = includeDetails
|
||||
self.watchChildren = watchChildren
|
||||
self.notificationName = notificationName
|
||||
self.includeElementDetails = includeElementDetails
|
||||
self.maxDepthForSearch = maxDepthForSearch
|
||||
}
|
||||
}
|
||||
|
||||
// Command struct for collectAll
|
||||
public struct CollectAllCommand: Sendable {
|
||||
public let appIdentifier: String?
|
||||
public let attributesToReturn: [String]?
|
||||
public let maxDepth: Int
|
||||
public let filterCriteria: [String: String]? // JSON string for criteria, or can be decoded
|
||||
public let valueFormatOption: ValueFormatOption?
|
||||
|
||||
public init(
|
||||
appIdentifier: String? = nil, // Provide default nil
|
||||
attributesToReturn: [String]? = nil,
|
||||
maxDepth: Int = 10,
|
||||
filterCriteria: [String: String]? = nil,
|
||||
valueFormatOption: ValueFormatOption? = .smart
|
||||
) {
|
||||
self.appIdentifier = appIdentifier
|
||||
self.attributesToReturn = attributesToReturn
|
||||
self.maxDepth = maxDepth
|
||||
self.filterCriteria = filterCriteria
|
||||
self.valueFormatOption = valueFormatOption
|
||||
}
|
||||
}
|
||||
|
||||
// Batch command structures
|
||||
public struct AXBatchCommand: Sendable {
|
||||
public struct SubCommandEnvelope: Sendable {
|
||||
public let commandID: String
|
||||
public let command: AXCommand
|
||||
|
||||
public init(commandID: String, command: AXCommand) {
|
||||
self.commandID = commandID
|
||||
self.command = command
|
||||
}
|
||||
}
|
||||
|
||||
public let commands: [SubCommandEnvelope]
|
||||
|
||||
public init(commands: [SubCommandEnvelope]) {
|
||||
self.commands = commands
|
||||
}
|
||||
}
|
||||
|
||||
// Alias for backward compatibility if needed
|
||||
public typealias AXSubCommand = AXCommand
|
||||
public typealias BatchCommandEnvelope = AXBatchCommand
|
||||
|
||||
@ -1,10 +1,70 @@
|
||||
// Models.swift - Contains core data models and type aliases
|
||||
|
||||
import Foundation
|
||||
import ApplicationServices // Added for AXUIElementGetTypeID
|
||||
|
||||
// Type alias for element attributes dictionary
|
||||
public typealias ElementAttributes = [String: AnyCodable]
|
||||
|
||||
// Wrapper for attribute values to make them Codable and handle Any
|
||||
public struct AXValueWrapper: Codable, Sendable {
|
||||
public var anyValue: AnyCodable? // This can be nil if the attribute itself had no value or was absent
|
||||
|
||||
@MainActor // Added @MainActor to allow calling element.briefDescription
|
||||
public init(value: Any?) {
|
||||
let typeOfOriginalValue = String(describing: type(of: value))
|
||||
axDebugLog("AXVW.init: OrigType='\(typeOfOriginalValue)', Val=\(String(describing: value).prefix(100))")
|
||||
|
||||
if let unwrappedValue = value {
|
||||
let typeOfUnwrappedValue = String(describing: type(of: unwrappedValue))
|
||||
axDebugLog("AXVW.init: UnwrappedType='\(typeOfUnwrappedValue)'")
|
||||
|
||||
if let array = unwrappedValue as? [Any?] {
|
||||
axDebugLog("AXVW.init: Detected Array. Count: \(array.count)")
|
||||
// Sanitize each item and wrap the resulting array of sanitized items in AnyCodable
|
||||
self.anyValue = AnyCodable(array.map { AXValueWrapper.recursivelySanitize($0) })
|
||||
} else if let dict = unwrappedValue as? [String: Any?] {
|
||||
axDebugLog("AXVW.init: Detected Dictionary. Count: \(dict.count)")
|
||||
self.anyValue = AnyCodable(dict.mapValues { AXValueWrapper.recursivelySanitize($0) })
|
||||
} else {
|
||||
// Handle single, non-collection items
|
||||
self.anyValue = AnyCodable(AXValueWrapper.recursivelySanitize(unwrappedValue))
|
||||
}
|
||||
} else { // value was nil (absence of value)
|
||||
axDebugLog("AXVW.init: Original value was nil.")
|
||||
self.anyValue = nil // The AXValueWrapper's own anyValue property is nil
|
||||
}
|
||||
}
|
||||
|
||||
// Static helper to sanitize individual items, called recursively by init for collections
|
||||
@MainActor
|
||||
private static func recursivelySanitize(_ item: Any?) -> Any { // Returns Any (basic types or () for nil)
|
||||
guard let anItem = item else { return () } // Convert nil to AnyCodable's nil marker
|
||||
let cfItem = anItem as CFTypeRef
|
||||
if CFGetTypeID(cfItem) == CFNullGetTypeID() { return () } // NSNull to AnyCodable's nil
|
||||
if CFGetTypeID(cfItem) == AXUIElementGetTypeID() { return "<AXUIElement_RS>" }
|
||||
if let element = anItem as? Element { return "<Element_RS: \(element.briefDescription(option: .raw))>" }
|
||||
|
||||
// If it's a collection, recurse. This handles nested collections.
|
||||
// Note: This recursive call inside a static func might lead to issues if not careful with types.
|
||||
// However, we are returning basic types or placeholders from the checks above.
|
||||
if let array = anItem as? [Any?] {
|
||||
return array.map { recursivelySanitize($0) }
|
||||
}
|
||||
if let dict = anItem as? [String: Any?] {
|
||||
return dict.mapValues { recursivelySanitize($0) }
|
||||
}
|
||||
|
||||
// For basic, already encodable types, return as is.
|
||||
// This assumes String, Int, Double, Bool are passed through.
|
||||
return anItem
|
||||
}
|
||||
|
||||
// If AnyCodable has trouble with certain AX types (like AXUIElementRef),
|
||||
// custom Encodable/Decodable logic might be needed here or in AnyCodable itself.
|
||||
// For instance, AXUIElementRef might be encoded as a placeholder string or an empty dict.
|
||||
}
|
||||
|
||||
public struct AXElement: Codable, HandlerDataRepresentable {
|
||||
public var attributes: ElementAttributes?
|
||||
public var path: [String]?
|
||||
|
||||
@ -78,7 +78,7 @@ extension Element {
|
||||
private func handleEmptyDescription() -> String {
|
||||
axDebugLog(
|
||||
"briefDescription: No descriptive attributes found, falling back to underlyingElement description.",
|
||||
details: ["element": String(describing: self.underlyingElement)]
|
||||
details: ["element": AnyCodable(String(describing: self.underlyingElement))]
|
||||
)
|
||||
return String(describing: self.underlyingElement)
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ extension Element {
|
||||
} else {
|
||||
// Use the global axDebugLog helper function for simplicity and correctness
|
||||
axDebugLog("Failed to get PID for element: \(error.rawValue)",
|
||||
details: ["element": String(describing: self.underlyingElement)])
|
||||
details: ["element": AnyCodable(String(describing: self.underlyingElement))])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -381,4 +381,30 @@ extension Element {
|
||||
}
|
||||
return Element.application(for: pid) // application(for:) is sync
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func elementAtPoint(_ point: CGPoint, pid: pid_t) -> Element? {
|
||||
let appAXUIElement = AXUIElement.application(pid: pid)
|
||||
guard let elementAXUIElement = AXUIElement.elementAtPosition(in: appAXUIElement, x: Float(point.x), y: Float(point.y)) else {
|
||||
return nil
|
||||
}
|
||||
return Element(elementAXUIElement)
|
||||
}
|
||||
|
||||
// MARK: - Path Generation
|
||||
|
||||
@MainActor
|
||||
public func path() -> Path? {
|
||||
let pathArray = self.generatePathArray()
|
||||
return Path(components: pathArray)
|
||||
}
|
||||
}
|
||||
|
||||
// Path structure to represent element path
|
||||
public struct Path {
|
||||
public let components: [String]
|
||||
|
||||
public init(components: [String]) {
|
||||
self.components = components
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
// ModelEnums.swift - Contains enum definitions for the AXorcist models
|
||||
|
||||
import Foundation
|
||||
|
||||
// Enum for output formatting options
|
||||
public enum OutputFormat: String, Codable {
|
||||
case smart // Default, tries to be concise and informative
|
||||
case verbose // More detailed output, includes more attributes/info
|
||||
case textContent // Primarily extracts textual content
|
||||
case jsonString // Returns the attributes as a JSON string (new)
|
||||
}
|
||||
|
||||
// Define CommandType enum
|
||||
public enum CommandType: String, Codable {
|
||||
case query
|
||||
case performAction
|
||||
case getAttributes
|
||||
case batch
|
||||
case describeElement
|
||||
case getFocusedElement
|
||||
case collectAll
|
||||
case extractText
|
||||
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
|
||||
}
|
||||
|
||||
// Enum for how values should be formatted, especially when dealing with complex types
|
||||
public enum ValueFormatOption: String, Codable {
|
||||
case smart // Try to provide the most useful representation (e.g., string for URL, number for size)
|
||||
case raw // Provide the raw underlying value if possible (e.g., CFTypeRef as String description, or basic data type)
|
||||
case stringified // Force a string representation of the value
|
||||
}
|
||||
@ -2,9 +2,110 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - AXErrorCode
|
||||
|
||||
// Error codes for AXorcist operations
|
||||
public enum AXErrorCode: String, Codable, Sendable {
|
||||
case elementNotFound = "element_not_found"
|
||||
case actionFailed = "action_failed"
|
||||
case attributeNotFound = "attribute_not_found"
|
||||
case invalidCommand = "invalid_command"
|
||||
case unknownCommand = "unknown_command"
|
||||
case internalError = "internal_error"
|
||||
case permissionDenied = "permission_denied"
|
||||
case invalidParameter = "invalid_parameter"
|
||||
case timeout = "timeout"
|
||||
case observationFailed = "observation_failed"
|
||||
case applicationNotFound = "application_not_found"
|
||||
case batchOperationFailed = "batch_operation_failed"
|
||||
case actionNotSupported = "action_not_supported"
|
||||
}
|
||||
|
||||
// MARK: - AXResponse
|
||||
|
||||
// Main response enum for AXorcist operations
|
||||
public enum AXResponse: Sendable {
|
||||
case success(payload: AnyCodable?, logs: [String]?)
|
||||
case error(message: String, code: AXErrorCode, logs: [String]?)
|
||||
|
||||
// Computed property for status
|
||||
public var status: String {
|
||||
switch self {
|
||||
case .success: return "success"
|
||||
case .error: return "error"
|
||||
}
|
||||
}
|
||||
|
||||
// Computed property for payload
|
||||
public var payload: AnyCodable? {
|
||||
switch self {
|
||||
case .success(let payload, _): return payload
|
||||
case .error: return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Computed property for error
|
||||
public var error: (message: String, code: AXErrorCode)? {
|
||||
switch self {
|
||||
case .success: return nil
|
||||
case .error(let message, let code, _): return (message, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Computed property for logs
|
||||
public var logs: [String]? {
|
||||
switch self {
|
||||
case .success(_, let logs): return logs
|
||||
case .error(_, _, let logs): return logs
|
||||
}
|
||||
}
|
||||
|
||||
// Static factory methods
|
||||
public static func successResponse(payload: AnyCodable?, logs: [String]? = nil) -> AXResponse {
|
||||
return .success(payload: payload, logs: logs)
|
||||
}
|
||||
|
||||
public static func errorResponse(message: String, code: AXErrorCode, logs: [String]? = nil) -> AXResponse {
|
||||
return .error(message: message, code: code, logs: logs)
|
||||
}
|
||||
}
|
||||
|
||||
// New protocol for generic data in HandlerResponse
|
||||
public protocol HandlerDataRepresentable: Codable {}
|
||||
|
||||
// Definition for AXElementData based on usage in AXorcist+QueryHandlers.swift
|
||||
public struct AXElementData: Codable, HandlerDataRepresentable {
|
||||
public var briefDescription: String?
|
||||
public var role: String?
|
||||
public var attributes: [String: AXValueWrapper]? // Assuming AXValueWrapper is Codable
|
||||
public var allPossibleAttributes: [String]?
|
||||
public var textualContent: String?
|
||||
public var childrenBriefDescriptions: [String]?
|
||||
public var fullAXDescription: String?
|
||||
// Add path here as it's often part of element data
|
||||
public var path: [String]?
|
||||
|
||||
public init(
|
||||
briefDescription: String? = nil,
|
||||
role: String? = nil,
|
||||
attributes: [String: AXValueWrapper]? = nil,
|
||||
allPossibleAttributes: [String]? = nil,
|
||||
textualContent: String? = nil,
|
||||
childrenBriefDescriptions: [String]? = nil,
|
||||
fullAXDescription: String? = nil,
|
||||
path: [String]? = nil
|
||||
) {
|
||||
self.briefDescription = briefDescription
|
||||
self.role = role
|
||||
self.attributes = attributes
|
||||
self.allPossibleAttributes = allPossibleAttributes
|
||||
self.textualContent = textualContent
|
||||
self.childrenBriefDescriptions = childrenBriefDescriptions
|
||||
self.fullAXDescription = fullAXDescription
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
|
||||
// Make existing relevant models conform
|
||||
// AXElement is defined in DataModels.swift, so we'll make it conform there later.
|
||||
// For now, assume it will.
|
||||
@ -261,6 +362,59 @@ public struct BatchResponse: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Additional Payload Structs
|
||||
|
||||
// NoFocusPayload for when no focused element is found
|
||||
public struct NoFocusPayload: Codable, HandlerDataRepresentable {
|
||||
public let message: String
|
||||
|
||||
public init(message: String) {
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
// TextPayload for text extraction
|
||||
public struct TextPayload: Codable, HandlerDataRepresentable {
|
||||
public let text: String
|
||||
|
||||
public init(text: String) {
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
// BatchResponsePayload for batch operations
|
||||
public struct BatchResponsePayload: Codable, HandlerDataRepresentable {
|
||||
public let results: [AnyCodable?]?
|
||||
public let errors: [String]?
|
||||
|
||||
public init(results: [AnyCodable?]?, errors: [String]?) {
|
||||
self.results = results
|
||||
self.errors = errors
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AXElementDescription
|
||||
|
||||
// Structure for element tree descriptions
|
||||
public struct AXElementDescription: Codable, Sendable {
|
||||
public let briefDescription: String?
|
||||
public let role: String?
|
||||
public let attributes: [String: AXValueWrapper]?
|
||||
public let children: [AXElementDescription]?
|
||||
|
||||
public init(
|
||||
briefDescription: String?,
|
||||
role: String?,
|
||||
attributes: [String: AXValueWrapper]?,
|
||||
children: [AXElementDescription]? = nil
|
||||
) {
|
||||
self.briefDescription = briefDescription
|
||||
self.role = role
|
||||
self.attributes = attributes
|
||||
self.children = children
|
||||
}
|
||||
}
|
||||
|
||||
// Structure for custom JSON output of handleCollectAll
|
||||
public struct CollectAllOutput: Codable {
|
||||
public let commandId: String
|
||||
|
||||
@ -1,288 +0,0 @@
|
||||
// AXorcist+ActionHandlers.swift - Action and data operation handlers
|
||||
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Darwin
|
||||
import Foundation
|
||||
// import Defaults // REMOVED
|
||||
// import removed - logging utilities now in same module
|
||||
|
||||
// MARK: - Action & Data Handlers Extension
|
||||
extension AXorcist {
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
|
||||
@MainActor
|
||||
private func executeStandardAccessibilityAction(
|
||||
_ axActionName: CFString,
|
||||
on targetElement: Element,
|
||||
actionNameForLog: String
|
||||
) -> AXError {
|
||||
let axStatus = AXUIElementPerformAction(targetElement.underlyingElement, axActionName)
|
||||
if axStatus != .success {
|
||||
let errorMessage = "[AXorcist.handlePerformAction] Failed to perform " +
|
||||
"\(actionNameForLog) action: \(axErrorToString(axStatus))"
|
||||
axErrorLog(errorMessage) // Use global logger
|
||||
}
|
||||
return axStatus
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func executeSetAttributeValueAction(
|
||||
attributeName: String,
|
||||
value: AnyCodable?,
|
||||
on targetElement: Element
|
||||
) -> (errorMessage: String?, axStatus: AXError) {
|
||||
guard let valueToSet = value?.value else {
|
||||
let errorMsg = "Value for set attribute '\(attributeName)' is nil."
|
||||
axErrorLog(errorMsg)
|
||||
return (errorMsg, .cannotComplete)
|
||||
}
|
||||
|
||||
guard targetElement.isAttributeSettable(named: attributeName) else {
|
||||
let errorMsg = "Attribute '\(attributeName)' is not settable on element \(targetElement.briefDescription(option: ValueFormatOption.smart))."
|
||||
axErrorLog(errorMsg)
|
||||
return (errorMsg, .attributeUnsupported)
|
||||
}
|
||||
|
||||
// Convert the value to CFTypeRef if possible, handle different types
|
||||
var cfValue: CFTypeRef?
|
||||
|
||||
if let stringValue = valueToSet as? String {
|
||||
cfValue = stringValue as CFString
|
||||
} else if let boolValue = valueToSet as? Bool {
|
||||
cfValue = (boolValue ? kCFBooleanTrue : kCFBooleanFalse) as CFBoolean
|
||||
} else if let numberValue = valueToSet as? NSNumber { // Handles Int, Double, etc.
|
||||
cfValue = numberValue
|
||||
} else if let objectArray = valueToSet as? [AnyObject] { // If valueToSet is directly [AnyObject]
|
||||
cfValue = objectArray as CFArray // Then bridge to CFArray
|
||||
} else if valueToSet is [Any] { // Check if it's [Any] but not caught by [AnyObject] (e.g. array of value types)
|
||||
// This case is problematic for CFArray which expects objects.
|
||||
let errorMsg = "Cannot convert array containing non-object types to CFArray for attribute '\(attributeName)'."
|
||||
axErrorLog(errorMsg)
|
||||
return (errorMsg, .illegalArgument)
|
||||
} else {
|
||||
// For other types, attempt direct casting if it's already a CFTypeRef-compatible type
|
||||
// This part might need more robust type checking and conversion
|
||||
// CFGetTypeID(valueToSet as CFTypeRef) != 0 might be a check, but Swift types might not bridge directly
|
||||
// For simplicity, we'll assume if it's not a common type, it might be problematic.
|
||||
let errorMsg = "Unsupported value type '\(type(of: valueToSet))' for attribute '\(attributeName)'."
|
||||
axErrorLog(errorMsg)
|
||||
return (errorMsg, .illegalArgument)
|
||||
}
|
||||
|
||||
guard let finalCFValue = cfValue else {
|
||||
let errorMsg = "Failed to convert value for attribute '\(attributeName)' to a compatible CFType."
|
||||
axErrorLog(errorMsg)
|
||||
return (errorMsg, .cannotComplete)
|
||||
}
|
||||
|
||||
let axStatus = AXUIElementSetAttributeValue(targetElement.underlyingElement, attributeName as CFString, finalCFValue)
|
||||
if axStatus != .success {
|
||||
let errorMsg = "Failed to set attribute '\(attributeName)': \(axErrorToString(axStatus))"
|
||||
axErrorLog(errorMsg)
|
||||
return (errorMsg, axStatus)
|
||||
}
|
||||
return (nil, .success)
|
||||
}
|
||||
|
||||
// Temporary placeholder until actual definition is found or created
|
||||
public typealias ActionValueCodable = AnyCodable
|
||||
|
||||
// Temporary simple struct for ActionResponse
|
||||
struct ActionResponse: Codable {
|
||||
var success: Bool
|
||||
var message: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func handlePerformAction(
|
||||
for application: String?,
|
||||
locator: Locator,
|
||||
actionName: String,
|
||||
actionValue: AnyCodable? = nil,
|
||||
maxDepth: Int? = nil
|
||||
) async -> HandlerResponse {
|
||||
let logMessage2 = "handlePerformAction: App=\(application ?? AXMiscConstants.focusedApplicationKey), Locator=\(locator), Action=\(actionName), Value=\(String(describing: actionValue))"
|
||||
axInfoLog(logMessage2)
|
||||
|
||||
let searchMaxDepth = maxDepth ?? AXMiscConstants.defaultMaxDepthSearch
|
||||
|
||||
let findResult = await findTargetElement(
|
||||
for: application ?? AXMiscConstants.focusedApplicationKey,
|
||||
locator: locator,
|
||||
maxDepthForSearch: searchMaxDepth
|
||||
)
|
||||
|
||||
if let targetElement = findResult.element {
|
||||
axDebugLog("handlePerformAction: Element found: \(targetElement.briefDescription(option: ValueFormatOption.smart))")
|
||||
// Proceed with targetElement
|
||||
let axStatus: AXError
|
||||
var actionErrorString: String?
|
||||
|
||||
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(option: ValueFormatOption.smart)).")
|
||||
return HandlerResponse(data: AnyCodable(PerformResponse(commandId: "", success: true)))
|
||||
} else {
|
||||
let finalErrorMessage = actionErrorString ?? "Action '\(actionName)' failed on \(targetElement.briefDescription(option: ValueFormatOption.smart)) 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: errorMessage)
|
||||
} else {
|
||||
// Should not happen if findTargetElement always returns either element or error
|
||||
let errorMessage = "handlePerformAction: Unknown error finding element."
|
||||
axErrorLog(errorMessage)
|
||||
return HandlerResponse(error: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func handleExtractText(
|
||||
for application: String?,
|
||||
locator: Locator,
|
||||
maxDepth: Int? = nil
|
||||
) async -> HandlerResponse {
|
||||
let logMessage3 = "handleExtractText: App=\(application ?? AXMiscConstants.focusedApplicationKey), Locator=\(locator)"
|
||||
axInfoLog(logMessage3)
|
||||
|
||||
let searchMaxDepth = maxDepth ?? AXMiscConstants.defaultMaxDepthSearch
|
||||
|
||||
let findResult = await findTargetElement(
|
||||
for: application ?? AXMiscConstants.focusedApplicationKey,
|
||||
locator: locator,
|
||||
maxDepthForSearch: searchMaxDepth
|
||||
)
|
||||
|
||||
let appElementInstance = applicationElement(for: application ?? AXMiscConstants.focusedApplicationKey)
|
||||
|
||||
if let targetElement = findResult.element {
|
||||
axDebugLog("handleExtractText: Element found: \(targetElement.briefDescription(option: ValueFormatOption.smart))")
|
||||
// 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(Attribute(AXAttributeNames.kAXTitleAttribute)) { allTextValues.append(title) }
|
||||
if let desc: String = targetElement.attribute(Attribute(AXAttributeNames.kAXDescriptionAttribute)) { allTextValues.append(desc) }
|
||||
if let valStr: String = targetElement.attribute(Attribute<String>(AXAttributeNames.kAXValueAttribute)) { allTextValues.append(valStr) }
|
||||
if let selectedText: String = targetElement.attribute(Attribute(AXAttributeNames.kAXSelectedTextAttribute)) { allTextValues.append(selectedText) }
|
||||
if let placeholder: String = targetElement.attribute(Attribute(AXAttributeNames.kAXPlaceholderValueAttribute)) { allTextValues.append(placeholder) }
|
||||
|
||||
let combinedText = allTextValues.joined(separator: " ").lowercased()
|
||||
|
||||
if combinedText.isEmpty {
|
||||
axDebugLog("No textual content found for element: \(targetElement.briefDescription(option: ValueFormatOption.smart))")
|
||||
return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: nil)), error: "No textual content found")
|
||||
} else {
|
||||
axDebugLog("Extracted text: '\(combinedText)' from element: \(targetElement.briefDescription(option: ValueFormatOption.smart))")
|
||||
return HandlerResponse(data: AnyCodable(TextExtractionResponse(textContent: combinedText.isEmpty ? nil : combinedText)))
|
||||
}
|
||||
} else if let errorMsg = findResult.error {
|
||||
let errorMessage = "handleExtractText: Error finding element: \(errorMsg)"
|
||||
axErrorLog(errorMessage)
|
||||
return HandlerResponse(error: errorMessage)
|
||||
} else {
|
||||
let errorMessage = "handleExtractText: Unknown error finding element."
|
||||
axErrorLog(errorMessage)
|
||||
return HandlerResponse(error: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Set Focused Value Handler
|
||||
|
||||
@MainActor
|
||||
public func handleSetFocusedValue(
|
||||
for applicationName: String?,
|
||||
locator: Locator?, // Optional: to verify the focused element if provided
|
||||
actionName: String, // Typically kAXValueAttribute or similar
|
||||
actionValue: AnyCodable? // The value to set
|
||||
) async -> HandlerResponse {
|
||||
let appID = applicationName ?? AXMiscConstants.focusedApplicationKey
|
||||
axInfoLog("[handleSetFocusedValue] App=\(appID), Locator=\(locator != nil ? "criteria: \(locator!.criteria)" : "nil"), Action=\(actionName), Value=\(String(describing: actionValue))")
|
||||
|
||||
guard let appElement = applicationElement(for: appID) else {
|
||||
let errorMsg = "[handleSetFocusedValue] Could not get application element for app: \(appID)"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
|
||||
guard let focusedElement = appElement.focusedUIElement() else {
|
||||
let errorMsg = "[handleSetFocusedValue] Could not get focused element for app: \(appID)"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
axDebugLog("[handleSetFocusedValue] Found focused element: \(focusedElement.briefDescription())")
|
||||
|
||||
// Optional: Validate against locator if provided
|
||||
if let aLocator = locator {
|
||||
// evaluateElementAgainstCriteria is async
|
||||
let matchStatus = await evaluateElementAgainstCriteria(
|
||||
element: focusedElement,
|
||||
locator: aLocator,
|
||||
actionToVerify: nil, // Not verifying an action here, just the element itself
|
||||
depth: 0 // Depth is not relevant for a single element check
|
||||
)
|
||||
if matchStatus != .fullMatch {
|
||||
let errorMsg = "[handleSetFocusedValue] Focused element \(focusedElement.briefDescription()) does not match provided locator: criteria=\(aLocator.criteria)"
|
||||
axWarningLog(errorMsg)
|
||||
// Depending on strictness, one might return an error here or proceed.
|
||||
// For now, proceeding but logging a warning.
|
||||
}
|
||||
}
|
||||
|
||||
guard let valueToSet = actionValue else {
|
||||
let errorMsg = "[handleSetFocusedValue] Value to set is nil for attribute/action '\(actionName)'."
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
|
||||
// Use the existing helper for setting attribute values
|
||||
let setResult = executeSetAttributeValueAction(attributeName: actionName, value: valueToSet, on: focusedElement)
|
||||
|
||||
if setResult.axStatus == .success {
|
||||
axDebugLog("[handleSetFocusedValue] Action/Attribute '\(actionName)' set successfully on focused element \(focusedElement.briefDescription()).")
|
||||
return HandlerResponse(data: AnyCodable(PerformResponse(commandId: "", success: true)))
|
||||
} else {
|
||||
let finalErrorMessage = setResult.errorMessage ?? "[handleSetFocusedValue] Failed to set '\(actionName)' on focused element \(focusedElement.briefDescription()) with status: \(axErrorToString(setResult.axStatus))"
|
||||
axErrorLog(finalErrorMessage)
|
||||
return HandlerResponse(error: finalErrorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Define PerformResponse and TextExtractionResponse if they are not already globally available
|
||||
// For now, assuming they are defined elsewhere and are Codable.
|
||||
// struct PerformResponse: Codable { let commandId: String; let success: Bool }
|
||||
// struct TextExtractionResponse: Codable { let textContent: String? }
|
||||
|
||||
// Removed stub PathHintComponent struct - the canonical one is in SearchCriteriaUtils.swift
|
||||
// struct PathHintComponent {
|
||||
// let originalSegment: String?
|
||||
// }
|
||||
@ -1,84 +0,0 @@
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
// GlobalAXLogger is assumed to be available via imports in the main AXorcist module or AXorcistLib
|
||||
|
||||
extension AXorcist {
|
||||
|
||||
// MARK: - Attribute Access Handlers
|
||||
|
||||
/// Checks if a specific attribute of an accessibility element is settable.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - attributeName: The name of the attribute to check (e.g., kAXValueAttribute).
|
||||
/// - element: The `AXUIElement` to check.
|
||||
/// - Returns: `true` if the attribute is settable, `false` otherwise. Returns `false` if the element is nil or on error.
|
||||
@MainActor
|
||||
public func isAttributeSettable(
|
||||
_ attributeName: String,
|
||||
forElement element: AXUIElement?
|
||||
) -> Bool {
|
||||
guard let element = element else {
|
||||
// Log will only occur if GlobalAXLogger.isCurrentlyCollecting is true
|
||||
axDebugLog("isAttributeSettable: Element is nil for attribute '\(attributeName)'.")
|
||||
return false
|
||||
}
|
||||
|
||||
var isSettable: DarwinBoolean = false
|
||||
let error = AXUIElementIsAttributeSettable(element, attributeName as CFString, &isSettable)
|
||||
|
||||
if error != .success {
|
||||
axDebugLog("isAttributeSettable: Error checking if attribute '\(attributeName)' is settable. Error code: \(error.rawValue)")
|
||||
return false
|
||||
}
|
||||
|
||||
axDebugLog("isAttributeSettable: Attribute '\(attributeName)' is \(isSettable.boolValue ? "settable" : "not settable").")
|
||||
return isSettable.boolValue
|
||||
}
|
||||
|
||||
/// Sets the value of a specific attribute for an accessibility element.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - attributeName: The name of the attribute to set (e.g., kAXValueAttribute).
|
||||
/// - value: The new value for the attribute. Currently supports String values primarily.
|
||||
/// Other types might require specific `CFTypeRef` conversion.
|
||||
/// - element: The `AXUIElement` for which to set the attribute.
|
||||
/// - Returns: `true` if the attribute was set successfully, `false` otherwise.
|
||||
@MainActor
|
||||
public func setAttributeValue(
|
||||
_ attributeName: String,
|
||||
to value: Any,
|
||||
forElement element: AXUIElement?
|
||||
) -> Bool {
|
||||
guard let element = element else {
|
||||
axDebugLog("setAttributeValue: Element is nil for attribute '\(attributeName)'.")
|
||||
return false
|
||||
}
|
||||
|
||||
let cfValue: CFTypeRef?
|
||||
if let stringValue = value as? String {
|
||||
cfValue = stringValue as CFString
|
||||
} else if let numberValue = value as? NSNumber {
|
||||
cfValue = numberValue
|
||||
} else if CFGetTypeID(value as CFTypeRef) != 0 {
|
||||
cfValue = value as CFTypeRef
|
||||
} else {
|
||||
axWarningLog("setAttributeValue: Unsupported value type '\(type(of: value))' for attribute '\(attributeName)'.")
|
||||
return false
|
||||
}
|
||||
|
||||
guard let finalCFValue = cfValue else {
|
||||
axWarningLog("setAttributeValue: Failed to convert value '\(value)' to CFTypeRef for attribute '\(attributeName)'.")
|
||||
return false
|
||||
}
|
||||
|
||||
let error = AXUIElementSetAttributeValue(element, attributeName as CFString, finalCFValue)
|
||||
|
||||
if error == .success {
|
||||
axInfoLog("setAttributeValue: Successfully set attribute '\(attributeName)' to '\(String(describing: value).truncated(to: 100))'.") // Truncate potentially long value
|
||||
return true
|
||||
} else {
|
||||
axErrorLog("setAttributeValue: Error setting attribute '\(attributeName)' to '\(String(describing: value).truncated(to: 100))'. Error code: \(error.rawValue)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,242 +0,0 @@
|
||||
// AXorcist+BatchHandler.swift - Batch processing operations
|
||||
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CoreGraphics // For CGPoint, potentially used by getElementAtPoint logic
|
||||
import Foundation
|
||||
|
||||
// GlobalAXLogger and AXUtilities are assumed to be available
|
||||
|
||||
// MARK: - Batch Processing Handler Extension
|
||||
extension AXorcist {
|
||||
|
||||
@MainActor
|
||||
private func prepareLocator(for subCommandEnvelope: CommandEnvelope, existingLocator: Locator?) -> Locator? {
|
||||
guard let newLocator = existingLocator else {
|
||||
if let oldPathHint = subCommandEnvelope.pathHint, !oldPathHint.isEmpty {
|
||||
axWarningLog("SubCommand \(subCommandEnvelope.commandId) has a CommandEnvelope.pathHint (old [String] format) but no base locator. This old pathHint will NOT be used. Provide path hints via locator.rootElementPathHint in JSON format.")
|
||||
}
|
||||
return existingLocator
|
||||
}
|
||||
|
||||
// If CommandEnvelope.pathHint ([String]?) is provided, AND locator.rootElementPathHint ([JSONPathHintComponent]?) is NOT,
|
||||
// this indicates an attempt to use the old path hint format. We log a warning as it won't be used by the new system.
|
||||
if let topLevelOldPathHint = subCommandEnvelope.pathHint,
|
||||
!topLevelOldPathHint.isEmpty,
|
||||
newLocator.rootElementPathHint == nil || newLocator.rootElementPathHint!.isEmpty {
|
||||
axWarningLog("AXorcist+BatchHandler: CommandEnvelope.pathHint (old [String] format) provided for sub-command \(subCommandEnvelope.commandId), but new JSON format (locator.rootElementPathHint) is nil or empty. The old format pathHint will NOT be used. Please update your query to use the new JSON format for rootElementPathHint within the locator object.")
|
||||
// DO NOT ASSIGN: newLocator.rootElementPathHint = topLevelOldPathHint // This would be a type error
|
||||
}
|
||||
return newLocator
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func handleBatchCommands(commandEnvelopes: [CommandEnvelope], batchCommandID: String) async -> [HandlerResponse] {
|
||||
var batchResults: [HandlerResponse] = []
|
||||
axDebugLog("[AXorcist.handleBatchCommands][BatchID: \(batchCommandID)] Received \(commandEnvelopes.count) sub-commands.")
|
||||
|
||||
for subCommandEnvelope in commandEnvelopes {
|
||||
let result = await processSingleBatchCommand(subCommandEnvelope)
|
||||
batchResults.append(result)
|
||||
}
|
||||
|
||||
// Batch processing complete
|
||||
axDebugLog("[AXorcist.handleBatchCommands] Batch processing complete")
|
||||
|
||||
axDebugLog("[AXorcist.handleBatchCommands][BatchID: \(batchCommandID)] Completed batch command processing, returning \(batchResults.count) results.")
|
||||
return batchResults
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processSingleBatchCommand(_ subCommandEnvelope: CommandEnvelope) async -> HandlerResponse {
|
||||
let subCmdID = subCommandEnvelope.commandId
|
||||
axDebugLog("[AXorcist.handleBatchCommands][SubCmdID: \(subCmdID)] Processing sub-command: \(subCmdID), type: \(subCommandEnvelope.command)")
|
||||
|
||||
// Log operation details
|
||||
axDebugLog("[AXorcist.handleBatchCommands] Processing sub-command: \(subCmdID) for app: \(subCommandEnvelope.application ?? AXMiscConstants.focusedApplicationKey)")
|
||||
|
||||
switch subCommandEnvelope.command {
|
||||
case .getFocusedElement:
|
||||
return await processFocusedElementCommand(subCommandEnvelope)
|
||||
|
||||
case .getAttributes:
|
||||
return await processGetAttributes(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
case .query:
|
||||
return await processQuery(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
case .describeElement:
|
||||
return await processDescribeElement(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
case .performAction:
|
||||
return await processPerformAction(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 processExtractText(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
case .getElementAtPoint:
|
||||
return await processGetElementAtPoint(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
case .ping:
|
||||
return processPingCommand(subCmdID)
|
||||
|
||||
case .collectAll:
|
||||
return processUnsupportedCommand(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
case .observe:
|
||||
return processUnsupportedCommand(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
case .batch:
|
||||
// Nested batch commands are not supported
|
||||
axWarningLog("Command 'batch' found within batch. Nested batch commands are not supported.")
|
||||
return processUnsupportedCommand(subCommandEnvelope, subCmdID: subCmdID)
|
||||
|
||||
@unknown default:
|
||||
return processUnknownCommand(subCommandEnvelope, subCmdID: subCmdID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processFocusedElementCommand(_ subCommandEnvelope: CommandEnvelope) async -> HandlerResponse {
|
||||
return await self.handleGetFocusedElement(
|
||||
for: subCommandEnvelope.application,
|
||||
requestedAttributes: subCommandEnvelope.attributes
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processGetAttributes(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse {
|
||||
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: finalLocator!,
|
||||
requestedAttributes: subCommandEnvelope.attributes,
|
||||
maxDepth: subCommandEnvelope.maxElements,
|
||||
outputFormat: subCommandEnvelope.outputFormat
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processQuery(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse {
|
||||
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: finalLocator!,
|
||||
maxDepth: subCommandEnvelope.maxElements,
|
||||
requestedAttributes: subCommandEnvelope.attributes,
|
||||
outputFormat: subCommandEnvelope.outputFormat
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processDescribeElement(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse {
|
||||
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: finalLocator!,
|
||||
maxDepth: subCommandEnvelope.maxDepth,
|
||||
requestedAttributes: subCommandEnvelope.attributes,
|
||||
outputFormat: subCommandEnvelope.outputFormat
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processPerformAction(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse {
|
||||
guard let originalLocator = subCommandEnvelope.locator else {
|
||||
let errorMsg = "Locator missing for performAction in batch (sub-command ID: \(subCmdID))"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
guard let actionName = subCommandEnvelope.actionName else {
|
||||
let errorMsg = "Action name missing for performAction in batch (sub-command ID: \(subCmdID))"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
let finalLocator = prepareLocator(for: subCommandEnvelope, existingLocator: originalLocator)
|
||||
|
||||
return await self.handlePerformAction(
|
||||
for: subCommandEnvelope.application,
|
||||
locator: finalLocator!,
|
||||
actionName: actionName,
|
||||
actionValue: subCommandEnvelope.actionValue,
|
||||
maxDepth: subCommandEnvelope.maxElements
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processExtractText(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse {
|
||||
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 finalLocator = prepareLocator(for: subCommandEnvelope, existingLocator: originalLocator)
|
||||
|
||||
return await self.handleExtractText(
|
||||
for: subCommandEnvelope.application,
|
||||
locator: finalLocator!,
|
||||
maxDepth: subCommandEnvelope.maxElements
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processGetElementAtPoint(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) async -> HandlerResponse {
|
||||
guard let point = subCommandEnvelope.point else {
|
||||
let errorMsg = "Missing point for getElementAtPoint command (sub-command ID: \(subCmdID))"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
return await self.handleGetElementAtPoint(
|
||||
for: subCommandEnvelope.application,
|
||||
point: point,
|
||||
commandId: subCmdID,
|
||||
attributesToFetch: subCommandEnvelope.attributes,
|
||||
outputFormat: subCommandEnvelope.outputFormat
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processPingCommand(_ subCmdID: String) -> HandlerResponse {
|
||||
let pingMsg = "Ping command handled within batch (sub-command ID: \(subCmdID))"
|
||||
axInfoLog(pingMsg)
|
||||
return HandlerResponse(data: nil, error: nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processUnsupportedCommand(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) -> HandlerResponse {
|
||||
let errorMsg =
|
||||
"Command type '\(subCommandEnvelope.command)' not supported within batch execution by AXorcist (sub-command ID: \(subCmdID))"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func processUnknownCommand(_ subCommandEnvelope: CommandEnvelope, subCmdID: String) -> HandlerResponse {
|
||||
let errorMsg =
|
||||
"Unknown or unhandled command type '\(subCommandEnvelope.command)' in batch processing within AXorcist (sub-command ID: \(subCmdID))"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
}
|
||||
@ -1,326 +0,0 @@
|
||||
// AXorcist+CollectAllHandler.swift - CollectAll operation handler
|
||||
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
|
||||
// Define a new generic Response structure if one doesn't exist suitable for this context.
|
||||
// For now, we'll assume that a general Response structure is available or defined elsewhere.
|
||||
// If not, one would be:
|
||||
public struct ResponseContainer: Codable { // Renamed to avoid conflict if a `Response` type exists elsewhere
|
||||
public var commandId: String
|
||||
public var success: Bool
|
||||
public var command: String // e.g., "collectAll"
|
||||
public var message: String?
|
||||
public var data: ResponseData? // Using a new ResponseData enum/struct
|
||||
public var error: String?
|
||||
public var debugLogs: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case commandId = "command_id"
|
||||
case success
|
||||
case command
|
||||
case message
|
||||
case data
|
||||
case error
|
||||
case debugLogs = "debug_logs"
|
||||
}
|
||||
}
|
||||
|
||||
// Assuming CommandType is an enum with String raw values, e.g.:
|
||||
// public enum CommandType: String, Codable {
|
||||
// case query, collectAll, performAction, ping, extractText, batch
|
||||
// }
|
||||
|
||||
// AXElementData is now defined in TreeTraversal.swift
|
||||
// public struct AXElementData: Codable { ... }
|
||||
|
||||
public enum ResponseData: Codable {
|
||||
case elementsList([AXElementData])
|
||||
case element(AXElementData?)
|
||||
case textContent(String?)
|
||||
case status(String)
|
||||
case batchResults([ResponseContainer])
|
||||
}
|
||||
|
||||
// MARK: - CollectAll Handler Extension
|
||||
extension AXorcist {
|
||||
|
||||
@MainActor
|
||||
private func encode(_ output: CollectAllOutput) -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
do {
|
||||
let jsonData = try encoder.encode(output)
|
||||
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
|
||||
let cmdType = output.command
|
||||
let errorJson = """
|
||||
{"command_id":"\(cmdId)", \
|
||||
"success":false, \
|
||||
"command":"\(cmdType)", \
|
||||
"error_message":"Catastrophic JSON encoding failure for CollectAllOutput. Original error logged.", \
|
||||
"collected_elements":[], \
|
||||
"debug_logs":["Catastrophic JSON encoding failure as well."]}
|
||||
"""
|
||||
return errorJson
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func handleCollectAll(
|
||||
for appIdentifierOrNil: String?,
|
||||
locator: Locator?,
|
||||
maxDepth: Int?,
|
||||
requestedAttributes: [String]?,
|
||||
outputFormat: OutputFormat?,
|
||||
commandId: String?,
|
||||
debugCLI: Bool,
|
||||
filterCriteria: [String: String]? = nil
|
||||
) async -> String {
|
||||
let params = CollectAllParameters(
|
||||
appIdentifierOrNil: appIdentifierOrNil,
|
||||
locator: locator,
|
||||
maxDepth: maxDepth,
|
||||
requestedAttributes: requestedAttributes,
|
||||
outputFormat: outputFormat,
|
||||
commandId: commandId,
|
||||
focusedAppKey: AXMiscConstants.focusedApplicationKey,
|
||||
filterCriteria: filterCriteria
|
||||
)
|
||||
|
||||
logCollectAllStart(params)
|
||||
guard let appElement = applicationElement(for: params.appIdentifier) else {
|
||||
return createErrorResponse(
|
||||
commandId: params.effectiveCommandId,
|
||||
appIdentifier: params.appIdentifier,
|
||||
error: "Failed to get app element for identifier: \(params.appIdentifier)",
|
||||
debugCLI: debugCLI
|
||||
)
|
||||
}
|
||||
let startElementResult = await determineStartElementForCollectAll(
|
||||
appElement: appElement,
|
||||
locator: locator,
|
||||
params: params
|
||||
)
|
||||
|
||||
guard let startElement = startElementResult.element else {
|
||||
return createErrorResponse(
|
||||
commandId: params.effectiveCommandId,
|
||||
appIdentifier: params.appIdentifier,
|
||||
error: startElementResult.error ?? "Failed to determine start element for collectAll",
|
||||
debugCLI: debugCLI
|
||||
)
|
||||
}
|
||||
|
||||
let collectedElements = await performCollectionTraversal(
|
||||
startElement: startElement,
|
||||
appElement: appElement,
|
||||
params: params
|
||||
)
|
||||
return createSuccessResponse(
|
||||
commandId: params.effectiveCommandId,
|
||||
appIdentifier: params.appIdentifier,
|
||||
collectedElements: collectedElements,
|
||||
debugCLI: debugCLI
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct CollectAllParameters {
|
||||
let effectiveCommandId: String
|
||||
let appIdentifier: String
|
||||
let recursionDepthLimit: Int
|
||||
let attributesToFetch: [String]
|
||||
let effectiveOutputFormat: OutputFormat
|
||||
let locator: Locator?
|
||||
let filterCriteria: [String: String]?
|
||||
|
||||
init(
|
||||
appIdentifierOrNil: String?,
|
||||
locator: Locator?,
|
||||
maxDepth: Int?,
|
||||
requestedAttributes: [String]?,
|
||||
outputFormat: OutputFormat?,
|
||||
commandId: String?,
|
||||
focusedAppKey: String,
|
||||
filterCriteria: [String: String]?
|
||||
) {
|
||||
self.effectiveCommandId = commandId ?? "collectAll_internal_id_\(UUID().uuidString.prefix(8))"
|
||||
self.appIdentifier = appIdentifierOrNil ?? focusedAppKey
|
||||
self.recursionDepthLimit = (maxDepth != nil && maxDepth! >= 0)
|
||||
? maxDepth!
|
||||
: AXMiscConstants.defaultMaxDepthCollectAll
|
||||
self.attributesToFetch = requestedAttributes ?? AXorcist.defaultAttributesToFetch
|
||||
self.effectiveOutputFormat = outputFormat ?? .smart
|
||||
self.locator = locator
|
||||
self.filterCriteria = filterCriteria
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func logCollectAllStart(_ params: CollectAllParameters) {
|
||||
let appNameForLog = params.appIdentifier
|
||||
let locatorCriteriaDesc = params.locator?.criteria.isEmpty == false ? String(describing: params.locator!.criteria) : "nil"
|
||||
let locatorPathHintDesc = params.locator?.rootElementPathHint?.map { "(attr:\($0.attribute),val:\($0.value),d:\($0.depth ?? -1))" }.joined(separator: " -> ") ?? "nil"
|
||||
let maxDepthDesc = String(describing: params.recursionDepthLimit)
|
||||
|
||||
axInfoLog(
|
||||
"[AXorcist.handleCollectAll] Starting. App: \(appNameForLog), " +
|
||||
"LocatorCriteria: \(locatorCriteriaDesc), LocatorJSONPathHint: [\(locatorPathHintDesc)], MaxDepth: \(maxDepthDesc)"
|
||||
)
|
||||
axDebugLog(
|
||||
"Effective recursionDepthLimit: \(params.recursionDepthLimit), " +
|
||||
"attributesToFetch: \(params.attributesToFetch.count) items, " +
|
||||
"effectiveOutputFormat: \(params.effectiveOutputFormat.rawValue)"
|
||||
)
|
||||
axDebugLog("Using app identifier: \(params.appIdentifier)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func determineStartElementForCollectAll(
|
||||
appElement: Element,
|
||||
locator: Locator?,
|
||||
params: CollectAllParameters
|
||||
) async -> (element: Element?, error: String?) {
|
||||
if let jsonPathComponents = locator?.rootElementPathHint, !jsonPathComponents.isEmpty {
|
||||
let pathHintDebug = jsonPathComponents.map { "(attr:\($0.attribute),val:\($0.value),d:\($0.depth ?? -1))" }.joined(separator: " -> ")
|
||||
axDebugLog("[CollectAll] Navigating for start element using JSONPathHint: [\(pathHintDebug)] (\(jsonPathComponents.count) components)")
|
||||
|
||||
if let navigatedElement = await navigateToElementByJSONPathHint(
|
||||
pathHint: jsonPathComponents,
|
||||
initialSearchElement: appElement
|
||||
) {
|
||||
axDebugLog("[CollectAll] JSONPathHint navigation successful. Start element for collectAll: \(navigatedElement.briefDescription())")
|
||||
return (navigatedElement, nil)
|
||||
} else {
|
||||
let errorMsg = "[CollectAll] Failed to navigate to start element using JSONPathHint: [\(pathHintDebug)]"
|
||||
axWarningLog(errorMsg)
|
||||
return (nil, errorMsg)
|
||||
}
|
||||
} else {
|
||||
axDebugLog("[CollectAll] No rootElementPathHint (JSON) in locator or locator is nil. Starting collectAll from app root: \(appElement.briefDescription())")
|
||||
return (appElement, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func performCollectionTraversal(
|
||||
startElement: Element,
|
||||
appElement: Element,
|
||||
params: CollectAllParameters
|
||||
) async -> [AXElementData] {
|
||||
axDebugLog(
|
||||
"[CollectAll.performCollectionTraversal] Starting traversal from: \(startElement.briefDescription()), " +
|
||||
"MaxDepth: \(params.recursionDepthLimit)"
|
||||
)
|
||||
|
||||
// Convert filterCriteria dictionary to [Criterion] if needed
|
||||
let effectiveLocatorCriteria: [Criterion]
|
||||
if let locatorCriteria = params.locator?.criteria, !locatorCriteria.isEmpty {
|
||||
effectiveLocatorCriteria = locatorCriteria
|
||||
} else if let filterDict = params.filterCriteria, !filterDict.isEmpty {
|
||||
// Convert dictionary to [Criterion] array
|
||||
effectiveLocatorCriteria = filterDict.map { key, value in
|
||||
Criterion(attribute: key, value: value, match_type: nil)
|
||||
}
|
||||
} else {
|
||||
effectiveLocatorCriteria = []
|
||||
}
|
||||
let matchingLocator = Locator(criteria: effectiveLocatorCriteria)
|
||||
let collectedData = await collectAllElements(
|
||||
from: startElement,
|
||||
matching: matchingLocator,
|
||||
appElementForContext: appElement,
|
||||
attributesToFetch: params.attributesToFetch,
|
||||
outputFormat: params.effectiveOutputFormat,
|
||||
maxElements: AXMiscConstants.defaultMaxElementsToCollect,
|
||||
maxSearchDepth: params.recursionDepthLimit
|
||||
)
|
||||
|
||||
axDebugLog("[CollectAll.performCollectionTraversal] Traversal complete. Collected \(collectedData.count) AXElementData items.")
|
||||
return collectedData
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func createErrorResponse(
|
||||
commandId: String,
|
||||
appIdentifier: String,
|
||||
error: String,
|
||||
debugCLI: Bool
|
||||
) -> String {
|
||||
let logs = debugCLI ? axGetLogsAsStrings(format: .text) : nil
|
||||
let output = CollectAllOutput(
|
||||
commandId: commandId,
|
||||
success: false,
|
||||
command: "collectAll",
|
||||
collectedElements: nil,
|
||||
appIdentifier: appIdentifier,
|
||||
debugLogs: logs,
|
||||
message: error
|
||||
)
|
||||
return encode(output)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func createSuccessResponse(
|
||||
commandId: String,
|
||||
appIdentifier: String,
|
||||
collectedElements: [AXElementData],
|
||||
debugCLI: Bool
|
||||
) -> String {
|
||||
let logs = debugCLI ? axGetLogsAsStrings(format: .text) : nil
|
||||
let output = CollectAllOutput(
|
||||
commandId: commandId,
|
||||
success: true,
|
||||
command: "collectAll",
|
||||
collectedElements: collectedElements,
|
||||
appIdentifier: appIdentifier,
|
||||
debugLogs: logs,
|
||||
message: "Successfully collected \(collectedElements.count) elements."
|
||||
)
|
||||
return encode(output)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
// Assuming CollectAllOutput and ErrorDetails structs are defined appropriately.
|
||||
// Removed duplicate definition of CollectAllOutput, it's defined in Core/ResponseModels.swift
|
||||
// public struct CollectAllOutput: Codable {
|
||||
// public let commandId: String
|
||||
// public let success: Bool
|
||||
// public let command: String // e.g. "collectAll"
|
||||
// public var collectedElements: [AXElementData] = []
|
||||
// public var errorMessage: String?
|
||||
// public var debugLogs: [String]?
|
||||
// public var errorDetails: ErrorDetails?
|
||||
//
|
||||
// enum CodingKeys: String, CodingKey {
|
||||
// case commandId = "command_id"
|
||||
// case success
|
||||
// case command
|
||||
// case collectedElements = "collected_elements"
|
||||
// case errorMessage = "error_message"
|
||||
// case debugLogs = "debug_logs"
|
||||
// case errorDetails = "error_details"
|
||||
// }
|
||||
// }
|
||||
|
||||
// public struct ErrorDetails: Codable {
|
||||
// public var code: Int? // e.g., AXError raw value or a custom error code
|
||||
// public var domain: String? // e.g., "AXorcist.AXErrorDomain"
|
||||
// public var context: String? // Additional context about the error
|
||||
// }
|
||||
@ -1,203 +0,0 @@
|
||||
import AppKit // For pid_t, AXObserver, AXUIElement, kAXFocusedUIElementChangedNotification etc.
|
||||
import ApplicationServices
|
||||
// GlobalAXLogger is used instead of OSLog
|
||||
|
||||
// MARK: - Focus Tracking Handlers
|
||||
extension AXorcist {
|
||||
// Public typealias for the callback
|
||||
public typealias AXFocusChangeCallback = @MainActor (_ focusedElement: Element, _ pid: pid_t, _ notification: AXNotification) -> Void
|
||||
|
||||
// MARK: - New Implementation using AXObserverCenter
|
||||
|
||||
@MainActor
|
||||
public func startFocusTracking(
|
||||
for pid: pid_t,
|
||||
callback: @escaping AXFocusChangeCallback
|
||||
) -> Bool {
|
||||
axDebugLog("Attempting to start focus tracking for PID \(pid).")
|
||||
|
||||
// Stop existing tracking if any
|
||||
if self.focusTrackingPID != 0 || self.focusedUIElementToken != nil || self.focusedWindowToken != nil {
|
||||
axInfoLog("Focus tracking potentially active (PID \(self.focusTrackingPID), UI token: \(self.focusedUIElementToken != nil), Window token: \(self.focusedWindowToken != nil)). Stopping first.")
|
||||
_ = stopFocusTracking() // Ensure any previous tracking is fully stopped
|
||||
}
|
||||
|
||||
self.focusTrackingPID = pid
|
||||
self.focusTrackingCallback = callback
|
||||
|
||||
var success = true
|
||||
|
||||
// Subscribe to Focused UI Element Changed
|
||||
let focusedUIElementResult = AXObserverCenter.shared.subscribe(
|
||||
pid: pid,
|
||||
notification: .focusedUIElementChanged
|
||||
) { [weak self] eventPid, axNotification, rawElement, _ in
|
||||
guard let self = self, let cb = self.focusTrackingCallback else {
|
||||
axWarningLog("Focus tracking callback or self is nil for .focusedUIElementChanged. PID: \(eventPid)")
|
||||
return
|
||||
}
|
||||
let focusedElement = Element(rawElement)
|
||||
cb(focusedElement, eventPid, axNotification)
|
||||
axDebugLog("Focus tracking: .focusedUIElementChanged. Element: \(focusedElement.briefDescription()), PID: \(eventPid), Notification: \(axNotification.rawValue)")
|
||||
}
|
||||
|
||||
if case .success(let token) = focusedUIElementResult {
|
||||
self.focusedUIElementToken = token
|
||||
axInfoLog("Successfully subscribed to .focusedUIElementChanged for PID \(pid). Token: \(token.id)")
|
||||
} else if case .failure(let error) = focusedUIElementResult {
|
||||
axErrorLog("Failed to subscribe to .focusedUIElementChanged for PID \(pid). Error: \(error.localizedDescription)")
|
||||
success = false
|
||||
}
|
||||
|
||||
// Subscribe to Focused Window Changed
|
||||
let focusedWindowResult = AXObserverCenter.shared.subscribe(
|
||||
pid: pid,
|
||||
notification: .focusedWindowChanged
|
||||
) { [weak self] eventPid, axNotification, rawWindowElement, nsUserInfo in
|
||||
guard let self = self, let cb = self.focusTrackingCallback else {
|
||||
axWarningLog("Focus tracking callback or self is nil for .focusedWindowChanged. PID: \(eventPid)")
|
||||
return
|
||||
}
|
||||
let windowElement = Element(rawWindowElement)
|
||||
var actualFocusedElement: Element = windowElement
|
||||
if let uiInfo = nsUserInfo, let focusedCF = uiInfo[AXMiscConstants.focusedUIElementKey] {
|
||||
if CFGetTypeID(focusedCF as CFTypeRef) == AXUIElementGetTypeID() {
|
||||
actualFocusedElement = Element(focusedCF as! AXUIElement)
|
||||
} else {
|
||||
axWarningLog("userInfo contained kAXFocusedUIElementKey but it was not an AXUIElement. Type: \(CFGetTypeID(focusedCF as CFTypeRef))")
|
||||
}
|
||||
} else if let focusedEl = windowElement.focusedUIElement() {
|
||||
actualFocusedElement = focusedEl
|
||||
}
|
||||
cb(actualFocusedElement, eventPid, axNotification)
|
||||
axDebugLog("Focus tracking: .focusedWindowChanged. Actual Element: \(actualFocusedElement.briefDescription()), PID: \(eventPid), Notification: \(axNotification.rawValue)")
|
||||
}
|
||||
|
||||
if case .success(let token) = focusedWindowResult {
|
||||
self.focusedWindowToken = token
|
||||
axInfoLog("Successfully subscribed to .focusedWindowChanged for PID \(pid). Token: \(token.id)")
|
||||
} else if case .failure(let error) = focusedWindowResult {
|
||||
axErrorLog("Failed to subscribe to .focusedWindowChanged for PID \(pid). Error: \(error.localizedDescription)")
|
||||
success = false
|
||||
}
|
||||
|
||||
if success {
|
||||
axInfoLog("Successfully started focus tracking for PID \(pid).")
|
||||
} else {
|
||||
axErrorLog("Error starting focus tracking for PID \(pid). Cleaning up.")
|
||||
_ = stopFocusTracking() // Clean up any partial subscriptions
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func stopFocusTracking() -> Bool {
|
||||
guard self.focusTrackingPID != 0 || self.focusedUIElementToken != nil || self.focusedWindowToken != nil || self.systemWideFocusToken != nil else {
|
||||
axInfoLog("Focus tracking not active (no PID or tokens).")
|
||||
return true
|
||||
}
|
||||
|
||||
axInfoLog("Attempting to stop focus tracking for PID \(self.focusTrackingPID). UI Token: \(self.focusedUIElementToken != nil), Window Token: \(self.focusedWindowToken != nil), System Token: \(self.systemWideFocusToken != nil)")
|
||||
|
||||
var allSuccess = true
|
||||
|
||||
if let token = self.focusedUIElementToken {
|
||||
do {
|
||||
try AXObserverCenter.shared.unsubscribe(token: token)
|
||||
axInfoLog("Unsubscribed from .focusedUIElementChanged. PID: \(self.focusTrackingPID)")
|
||||
self.focusedUIElementToken = nil
|
||||
} catch {
|
||||
axErrorLog("Failed to unsubscribe from .focusedUIElementChanged for PID \(self.focusTrackingPID): \(error)")
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
if let token = self.focusedWindowToken {
|
||||
do {
|
||||
try AXObserverCenter.shared.unsubscribe(token: token)
|
||||
axInfoLog("Unsubscribed from .focusedWindowChanged. PID: \(self.focusTrackingPID)")
|
||||
self.focusedWindowToken = nil
|
||||
} catch {
|
||||
axErrorLog("Failed to unsubscribe from .focusedWindowChanged for PID \(self.focusTrackingPID): \(error)")
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
if let token = self.systemWideFocusToken {
|
||||
do {
|
||||
try AXObserverCenter.shared.unsubscribe(token: token)
|
||||
axInfoLog("Unsubscribed from system-wide focus tracking.")
|
||||
self.systemWideFocusToken = nil
|
||||
} catch {
|
||||
axErrorLog("Failed to unsubscribe from system-wide focus tracking: \(error)")
|
||||
allSuccess = false
|
||||
}
|
||||
}
|
||||
|
||||
self.focusTrackingPID = 0 // Reset PID regardless of unsubscribe success
|
||||
self.focusTrackingCallback = nil
|
||||
// focusTrackingObserver is not used with AXObserverCenter
|
||||
|
||||
if allSuccess {
|
||||
axInfoLog("Successfully stopped all focus tracking subscriptions.")
|
||||
} else {
|
||||
axWarningLog("Encountered errors while stopping focus tracking subscriptions.")
|
||||
}
|
||||
return allSuccess
|
||||
}
|
||||
|
||||
// MARK: - System-wide Focus Tracking
|
||||
|
||||
@MainActor
|
||||
public func startSystemWideFocusTracking(callback: @escaping AXFocusChangeCallback) -> Bool {
|
||||
axDebugLog("Attempting to start system-wide focus tracking.")
|
||||
|
||||
// Stop existing tracking if any
|
||||
if self.systemWideFocusToken != nil {
|
||||
axInfoLog("System-wide focus tracking already active. Stopping first.")
|
||||
_ = stopFocusTracking() // stopFocusTracking will handle systemWideFocusToken
|
||||
}
|
||||
|
||||
// Ensure other PID-specific tracking is also stopped
|
||||
if self.focusTrackingPID != 0 {
|
||||
axInfoLog("PID-specific focus tracking active (PID \(self.focusTrackingPID)). Stopping it as well.")
|
||||
_ = stopFocusTracking()
|
||||
}
|
||||
|
||||
self.focusTrackingCallback = callback // Store the callback
|
||||
|
||||
let systemWideResult = AXObserverCenter.shared.subscribe(
|
||||
pid: nil, // System-wide
|
||||
notification: .focusedApplicationChanged
|
||||
) { [weak self] eventPid, axNotification, rawAppElement, _ in
|
||||
guard let self = self, let cb = self.focusTrackingCallback else {
|
||||
axWarningLog("System-wide focus tracking callback or self is nil for .focusedApplicationChanged. PID: \(eventPid)")
|
||||
return
|
||||
}
|
||||
let appElement = Element(rawAppElement)
|
||||
var actualFocusedElement: Element = appElement
|
||||
if let focusedUI = appElement.focusedUIElement() {
|
||||
actualFocusedElement = focusedUI
|
||||
}
|
||||
cb(actualFocusedElement, eventPid, axNotification)
|
||||
axDebugLog("System-wide focus tracking: .focusedApplicationChanged. Actual Element: \(actualFocusedElement.briefDescription()), PID: \(eventPid), Notification: \(axNotification.rawValue)")
|
||||
}
|
||||
|
||||
switch systemWideResult {
|
||||
case .success(let token):
|
||||
self.systemWideFocusToken = token
|
||||
axInfoLog("Successfully subscribed to system-wide .focusedApplicationChanged. Token: \(token.id)")
|
||||
return true
|
||||
case .failure(let error):
|
||||
axErrorLog("Failed to subscribe to system-wide .focusedApplicationChanged. Error: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func stopSystemWideFocusTracking() -> Bool {
|
||||
axDebugLog("Attempting to stop system-wide focus tracking (will call general stopFocusTracking).")
|
||||
// stopFocusTracking() already handles the systemWideFocusToken
|
||||
return stopFocusTracking()
|
||||
}
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
// AXorcist+GetElementAtPointHandler.swift - Handler for GetElementAtPoint command
|
||||
|
||||
import Foundation
|
||||
import ApplicationServices // For CGPoint
|
||||
|
||||
extension AXorcist {
|
||||
@MainActor
|
||||
public func handleGetElementAtPoint(
|
||||
for application: String?, // Optional: application context
|
||||
point: CGPoint, // The screen coordinates
|
||||
commandId: String?,
|
||||
attributesToFetch: [String]? = nil, // Optional: attributes to fetch for the found element
|
||||
outputFormat: OutputFormat? = .smart,
|
||||
valueFormatOption: ValueFormatOption? = .smart, // Assuming ValueFormatOption exists
|
||||
debugCLI: Bool = false
|
||||
) async -> HandlerResponse {
|
||||
let effectiveCommandId = commandId ?? "getElementAtPoint_\(UUID().uuidString.prefix(8))"
|
||||
axInfoLog("[AXorcist.handleGetElementAtPoint][CmdID: \(effectiveCommandId)] App=\(application ?? "systemWide"), Point=(\(point.x), \(point.y))")
|
||||
|
||||
// 1. Determine the root element for hit-testing
|
||||
// If an application is specified, use that application's main window or element.
|
||||
// Otherwise, use the system-wide element.
|
||||
var searchRootElement: Element?
|
||||
if let appIdentifier = application {
|
||||
searchRootElement = applicationElement(for: appIdentifier)
|
||||
if searchRootElement == nil {
|
||||
let errorMsg = "Application not found: \(appIdentifier)"
|
||||
axErrorLog("[AXorcist.handleGetElementAtPoint][CmdID: \(effectiveCommandId)] \(errorMsg)")
|
||||
return HandlerResponse(data: nil, error: errorMsg)
|
||||
}
|
||||
} else {
|
||||
searchRootElement = Element.systemWide()
|
||||
}
|
||||
|
||||
guard let rootElement = searchRootElement else {
|
||||
// This case should ideally not be reached if the above logic is sound
|
||||
let errorMsg = "Could not determine root element for hit-testing."
|
||||
axErrorLog("[AXorcist.handleGetElementAtPoint][CmdID: \(effectiveCommandId)] \(errorMsg)")
|
||||
return HandlerResponse(data: nil, error: errorMsg)
|
||||
}
|
||||
|
||||
// 2. Perform the hit-test
|
||||
var hitElementRef: AXUIElement?
|
||||
let error = AXUIElementCopyElementAtPosition(rootElement.underlyingElement, Float(point.x), Float(point.y), &hitElementRef)
|
||||
|
||||
if error != .success {
|
||||
let errorMsg = "AXUIElementCopyElementAtPosition failed: \(axErrorToString(error))"
|
||||
axErrorLog("[AXorcist.handleGetElementAtPoint][CmdID: \(effectiveCommandId)] \(errorMsg)")
|
||||
return HandlerResponse(data: nil, error: errorMsg)
|
||||
}
|
||||
|
||||
guard let foundAxElement = hitElementRef else {
|
||||
let errorMsg = "No element found at point (\(point.x), \(point.y))"
|
||||
axInfoLog("[AXorcist.handleGetElementAtPoint][CmdID: \(effectiveCommandId)] \(errorMsg)")
|
||||
// Not necessarily an error, could be empty space. Return success with no data.
|
||||
return HandlerResponse(data: nil, error: nil)
|
||||
}
|
||||
|
||||
let foundElement = Element(foundAxElement)
|
||||
axInfoLog("[AXorcist.handleGetElementAtPoint][CmdID: \(effectiveCommandId)] Found element: \(foundElement.briefDescription(option: .smart))")
|
||||
|
||||
// 3. Fetch attributes if requested (similar to handleQuery)
|
||||
let (attributes, attrErrors) = await getElementAttributes(
|
||||
element: foundElement,
|
||||
attributes: attributesToFetch ?? AXorcist.defaultAttributesToFetch,
|
||||
outputFormat: outputFormat ?? .smart,
|
||||
valueFormatOption: valueFormatOption ?? .smart
|
||||
)
|
||||
|
||||
if !attrErrors.isEmpty {
|
||||
axWarningLog("[AXorcist.handleGetElementAtPoint][CmdID: \(effectiveCommandId)] Errors fetching attributes: \(attrErrors.map { $0.message }.joined(separator: "; "))")
|
||||
}
|
||||
|
||||
let appElementForPath = applicationElement(for: application ?? AXMiscConstants.focusedApplicationKey)
|
||||
|
||||
let elementData = AXElementData(
|
||||
path: foundElement.generatePathArray(upTo: appElementForPath),
|
||||
attributes: attributes,
|
||||
role: attributes[AXAttributeNames.kAXRoleAttribute]?.value as? String,
|
||||
computedName: foundElement.computedName()
|
||||
)
|
||||
|
||||
return HandlerResponse(data: AnyCodable(elementData), error: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Need to ensure ValueFormatOption is defined, if not already.
|
||||
// For now, assuming it exists, e.g., in ModelEnums.swift or similar.
|
||||
// public enum ValueFormatOption: String, Codable {
|
||||
// case smart
|
||||
// case raw
|
||||
// case stringified
|
||||
// }
|
||||
@ -1,357 +0,0 @@
|
||||
// AXorcist+QueryHandlers.swift - Query and search operation handlers
|
||||
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
// GlobalAXLogger is assumed to be available
|
||||
|
||||
// Define arrow separator constant for joining path hints
|
||||
// private let arrowSeparator = " -> " // No longer needed here
|
||||
|
||||
// MARK: - Query & Search Handlers Extension
|
||||
extension AXorcist {
|
||||
|
||||
// MARK: - handleQuery
|
||||
|
||||
@MainActor
|
||||
public func handleQuery(
|
||||
for appIdentifierOrNil: String?,
|
||||
locator: Locator,
|
||||
maxDepth: Int?,
|
||||
requestedAttributes: [String]?,
|
||||
outputFormat: OutputFormat?
|
||||
) async -> HandlerResponse {
|
||||
let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey
|
||||
axDebugLog("Handling query for app: \(appIdentifier), locator: \(locator)",
|
||||
file: #file, function: #function, line: #line)
|
||||
|
||||
// findTargetElement is sync
|
||||
let findResult = await findTargetElement(
|
||||
for: appIdentifier,
|
||||
locator: locator,
|
||||
maxDepthForSearch: maxDepth ?? AXMiscConstants.defaultMaxDepthSearch
|
||||
)
|
||||
|
||||
guard let foundElement = findResult.element else {
|
||||
return HandlerResponse(
|
||||
data: nil,
|
||||
error: findResult.error ?? "Element not found by handleQuery."
|
||||
)
|
||||
}
|
||||
|
||||
// applicationElement is sync
|
||||
guard let appElement = applicationElement(for: appIdentifier) else {
|
||||
axErrorLog("Application not found for path context: \(appIdentifier)")
|
||||
return await buildQueryResponse(
|
||||
element: foundElement,
|
||||
appElement: nil,
|
||||
requestedAttributes: requestedAttributes,
|
||||
outputFormat: outputFormat
|
||||
)
|
||||
}
|
||||
|
||||
return await buildQueryResponse(
|
||||
element: foundElement,
|
||||
appElement: appElement,
|
||||
requestedAttributes: requestedAttributes,
|
||||
outputFormat: outputFormat
|
||||
)
|
||||
}
|
||||
|
||||
// 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?) {
|
||||
// ... implementation removed ...
|
||||
}
|
||||
*/
|
||||
|
||||
// Helper: Find element with locator - REMOVED as findTargetElement handles this
|
||||
/*
|
||||
@MainActor
|
||||
private func findElementWithLocator(
|
||||
locator: Locator,
|
||||
effectiveElement: Element,
|
||||
appElement: Element,
|
||||
maxDepth: Int?
|
||||
) -> (element: Element?, error: String?) {
|
||||
// ... implementation removed ...
|
||||
}
|
||||
*/
|
||||
|
||||
// Helper: Find search start element - REMOVED as findTargetElement handles this
|
||||
/*
|
||||
@MainActor
|
||||
private func findSearchStartElement(
|
||||
locator: Locator,
|
||||
effectiveElement: Element,
|
||||
appElement: Element
|
||||
) -> (element: Element?, error: String?) {
|
||||
// ... implementation removed ...
|
||||
}
|
||||
*/
|
||||
|
||||
// Helper: Build query response - made internal to be accessible by other handlers
|
||||
@MainActor
|
||||
internal func buildQueryResponse(
|
||||
element: Element,
|
||||
appElement: Element?,
|
||||
requestedAttributes: [String]?,
|
||||
outputFormat: OutputFormat?
|
||||
) async -> HandlerResponse {
|
||||
let (attributes, _) = await getElementAttributes(
|
||||
element: element,
|
||||
attributes: requestedAttributes ?? [],
|
||||
outputFormat: outputFormat ?? .smart
|
||||
)
|
||||
// element.generatePathArray is sync
|
||||
let axElement = AXElement(
|
||||
attributes: attributes,
|
||||
path: element.generatePathArray(upTo: appElement)
|
||||
)
|
||||
|
||||
return HandlerResponse(
|
||||
data: AnyCodable(axElement),
|
||||
error: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - handleGetAttributes
|
||||
|
||||
@MainActor
|
||||
public func handleGetAttributes(
|
||||
for appIdentifierOrNil: String?,
|
||||
locator: Locator,
|
||||
requestedAttributes: [String]?,
|
||||
maxDepth: Int?,
|
||||
outputFormat: OutputFormat?
|
||||
) async -> HandlerResponse {
|
||||
let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey
|
||||
axDebugLog("Handling getAttributes for app: \(appIdentifier), locator: \(locator)",
|
||||
file: #file, function: #function, line: #line)
|
||||
|
||||
// findTargetElement is sync
|
||||
let findResult = await findTargetElement(
|
||||
for: appIdentifier,
|
||||
locator: locator,
|
||||
maxDepthForSearch: maxDepth ?? AXMiscConstants.defaultMaxDepthSearch
|
||||
)
|
||||
|
||||
guard let foundElement = findResult.element else {
|
||||
return HandlerResponse(
|
||||
data: nil,
|
||||
error: findResult.error ?? "Element not found by handleGetAttributes."
|
||||
)
|
||||
}
|
||||
|
||||
let (attributes, _) = await getElementAttributes(
|
||||
element: foundElement,
|
||||
attributes: requestedAttributes ?? AXorcist.defaultAttributesToFetch,
|
||||
outputFormat: outputFormat ?? .smart
|
||||
)
|
||||
|
||||
let axElementData = AXElement(attributes: attributes, path: nil)
|
||||
return HandlerResponse(data: AnyCodable(axElementData), error: nil)
|
||||
}
|
||||
|
||||
// MARK: - handleDescribeElement
|
||||
|
||||
@MainActor
|
||||
public func handleDescribeElement(
|
||||
for appIdentifierOrNil: String?,
|
||||
locator: Locator,
|
||||
maxDepth: Int?,
|
||||
requestedAttributes: [String]?,
|
||||
outputFormat: OutputFormat?
|
||||
) async -> HandlerResponse {
|
||||
let appIdentifier = appIdentifierOrNil ?? AXMiscConstants.focusedApplicationKey
|
||||
axDebugLog("Handling describeElement for app: \(appIdentifier), locator: \(locator)",
|
||||
file: #file, function: #function, line: #line)
|
||||
|
||||
let searchMaxDepth = AXMiscConstants.defaultMaxDepthSearch
|
||||
// findTargetElement is sync
|
||||
let findResult = await findTargetElement(
|
||||
for: appIdentifier,
|
||||
locator: locator,
|
||||
maxDepthForSearch: searchMaxDepth
|
||||
)
|
||||
|
||||
guard let foundElement = findResult.element else {
|
||||
return HandlerResponse(
|
||||
data: nil,
|
||||
error: findResult.error ?? "Element not found by handleDescribeElement."
|
||||
)
|
||||
}
|
||||
|
||||
// applicationElement is sync
|
||||
guard let appElement = applicationElement(for: appIdentifier) else {
|
||||
axErrorLog("Application not found for path context in describeElement: \(appIdentifier)")
|
||||
return HandlerResponse(error: "Application \(appIdentifier) not found for describeElement context.")
|
||||
}
|
||||
|
||||
let descriptionTreeMaxDepth = maxDepth ?? AXMiscConstants.defaultMaxDepthDescribe
|
||||
|
||||
// describeElementTree is async
|
||||
let elementTree = await describeElementTree(
|
||||
element: foundElement,
|
||||
appElement: appElement,
|
||||
maxDepth: descriptionTreeMaxDepth,
|
||||
currentDepth: 0,
|
||||
requestedAttributes: requestedAttributes,
|
||||
outputFormat: outputFormat ?? .smart
|
||||
)
|
||||
|
||||
return HandlerResponse(data: AnyCodable(elementTree), error: nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
internal func describeElementTree(
|
||||
element: Element,
|
||||
appElement: Element,
|
||||
maxDepth: Int,
|
||||
currentDepth: Int,
|
||||
requestedAttributes: [String]?,
|
||||
outputFormat: OutputFormat
|
||||
) async -> AXElementNode {
|
||||
let (attributes, _) = await getElementAttributes(
|
||||
element: element,
|
||||
attributes: requestedAttributes ?? AXorcist.defaultAttributesToFetch,
|
||||
outputFormat: outputFormat
|
||||
)
|
||||
// element.generatePathArray is sync
|
||||
let pathArray = element.generatePathArray(upTo: appElement)
|
||||
|
||||
var childrenNodes: [AXElementNode]?
|
||||
if currentDepth < maxDepth {
|
||||
// element.children is sync
|
||||
if let children = element.children() {
|
||||
childrenNodes = await withTaskGroup(of: AXElementNode.self) { group in
|
||||
for childElement in children {
|
||||
group.addTask {
|
||||
await self.describeElementTree(
|
||||
element: childElement,
|
||||
appElement: appElement,
|
||||
maxDepth: maxDepth,
|
||||
currentDepth: currentDepth + 1,
|
||||
requestedAttributes: requestedAttributes,
|
||||
outputFormat: outputFormat
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var results: [AXElementNode] = []
|
||||
for await node in group {
|
||||
results.append(node)
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
return AXElementNode(attributes: attributes, path: pathArray, children: childrenNodes)
|
||||
}
|
||||
|
||||
// Gets attributes of an element, handling errors and output format.
|
||||
// This function itself doesn't need to be async if its callees (element.attribute, element.computedName) are sync.
|
||||
// This is an internal helper, potentially an instance method if it uses instance state,
|
||||
// or could be a static/global utility if it doesn't.
|
||||
// Renaming to avoid conflict with the global getElementAttributes.
|
||||
@MainActor
|
||||
internal func fetchInstanceElementAttributes(element: Element, attributes names: [String], outputFormat: OutputFormat) -> (attributes: [String: AnyCodable], errors: [String]) {
|
||||
var fetchedAttributes: [String: AnyCodable] = [:]
|
||||
var errors: [String] = []
|
||||
var effectiveAttributeNames = names
|
||||
|
||||
if names.contains("*") || names.contains("all") {
|
||||
// element.attributeNames() is sync
|
||||
effectiveAttributeNames = element.attributeNames() ?? []
|
||||
// Ensure some defaults if wildcard is used
|
||||
let defaults: Set<String> = [AXAttributeNames.kAXRoleAttribute, AXAttributeNames.kAXTitleAttribute, AXAttributeNames.kAXRoleDescriptionAttribute]
|
||||
effectiveAttributeNames.append(contentsOf: defaults.filter { !effectiveAttributeNames.contains($0) })
|
||||
}
|
||||
|
||||
// Always try to include a few key attributes for identification if not present
|
||||
let minimumDefaults: Set<String> = [AXAttributeNames.kAXRoleAttribute, AXAttributeNames.kAXTitleAttribute, "computedName"]
|
||||
for defaultAttr in minimumDefaults {
|
||||
if !effectiveAttributeNames.contains(defaultAttr) {
|
||||
effectiveAttributeNames.append(defaultAttr)
|
||||
}
|
||||
}
|
||||
|
||||
for name in effectiveAttributeNames {
|
||||
if name == "computedName" { // Handle pseudo-attribute
|
||||
// element.computedName() is sync
|
||||
if let computed = element.computedName() {
|
||||
fetchedAttributes[name] = AnyCodable(computed)
|
||||
} else {
|
||||
// Optionally represent nil or skip
|
||||
}
|
||||
continue
|
||||
}
|
||||
// element.attribute() is sync
|
||||
if let value = element.attribute(Attribute<Any>(name)) { // Use Attribute<Any> for generic fetching
|
||||
fetchedAttributes[name] = AnyCodable(value)
|
||||
} else {
|
||||
// errors.append("Attribute '\(name)' not found or nil.") // Optionally log errors for missing attributes
|
||||
}
|
||||
}
|
||||
return (fetchedAttributes, errors)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
*/
|
||||
|
||||
// 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.
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
import AppKit // Required for NSScreen, CGPoint, pid_t
|
||||
import ApplicationServices
|
||||
import CoreGraphics // Ensure CGPoint is available for parameter type
|
||||
import Foundation
|
||||
// GlobalAXLogger should be available
|
||||
|
||||
// MARK: - Utility Handlers
|
||||
extension AXorcist {
|
||||
|
||||
/// Fetches basic identifying information (role and title) for a given accessibility element.
|
||||
/// This is useful for creating preview strings for elements referenced by attributes.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - element: The `AXUIElement` to get information for.
|
||||
/// - Returns: A tuple containing the preview string (or nil) and an array of debug log messages.
|
||||
@MainActor
|
||||
public func getPreviewString(
|
||||
forElement axElement: AXUIElement?
|
||||
) -> String? {
|
||||
guard let axUIElement = axElement else {
|
||||
axDebugLog("getPreviewString: Element is nil.")
|
||||
return nil
|
||||
}
|
||||
|
||||
let element = Element(axUIElement)
|
||||
let role = element.role()
|
||||
let title = element.title()
|
||||
|
||||
axDebugLog("getPreviewString: Fetched Role=\'\(role ?? "<nil>")\', Title=\'\(title ?? "<nil>")\' for element.")
|
||||
|
||||
if let roleValue = role, !roleValue.isEmpty, let titleValue = title, !titleValue.isEmpty {
|
||||
return "\(roleValue): \(titleValue)"
|
||||
} else if let roleValue = role, !roleValue.isEmpty {
|
||||
return roleValue
|
||||
} else if let titleValue = title, !titleValue.isEmpty {
|
||||
return titleValue
|
||||
} else {
|
||||
return "<AXUIElement>"
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the accessibility element at a given screen point for a specific application.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - point: The screen point to hit-test. Its coordinate system origin (bottom-left or top-left)
|
||||
/// is determined by `isScreenCoordinatesTopLeft`.
|
||||
/// - pid: The process identifier of the target application.
|
||||
/// - isScreenCoordinatesTopLeft: If `true`, `point` is assumed to have (0,0) at top-left of the screen.
|
||||
/// If `false` (default), `point` is assumed to be AppKit-style (bottom-left is (0,0))
|
||||
/// and will be converted internally.
|
||||
/// - isDebugLoggingEnabled: Whether to enable debug logging.
|
||||
/// - currentDebugLogs: An inout array for debug logs.
|
||||
/// - Returns: An `AXUIElement` if found, otherwise `nil`. The caller is responsible for releasing this element if non-nil.
|
||||
@MainActor
|
||||
public func getElementAtPoint(
|
||||
pid explicitPID: pid_t? = nil,
|
||||
point: CGPoint,
|
||||
appIdentifierOrNil: String? = nil, // Can be bundle ID or app name
|
||||
requestedAttributes: [String]? = nil,
|
||||
isScreenCoordinatesTopLeft: Bool = false
|
||||
) async -> HandlerResponse {
|
||||
|
||||
var targetPID: pid_t = 0
|
||||
var appElementForPath: Element?
|
||||
|
||||
if let explicitPID = explicitPID, explicitPID > 0 {
|
||||
targetPID = explicitPID
|
||||
axDebugLog("getElementAtPoint: Using explicit PID: \(targetPID)")
|
||||
} else if let appID = appIdentifierOrNil {
|
||||
if let runningApp = NSRunningApplication.runningApplications(withBundleIdentifier: appID).first {
|
||||
targetPID = runningApp.processIdentifier
|
||||
axDebugLog("getElementAtPoint: Found running app by bundle ID '\(appID)', PID: \(targetPID)")
|
||||
} else if let runningApp = NSRunningApplication.runningApplications(withBundleIdentifier: appID.contains(".app") ? appID : "\(appID).app").first { // Try adding .app
|
||||
targetPID = runningApp.processIdentifier
|
||||
axDebugLog("getElementAtPoint: Found running app by bundle ID '\(appID).app', PID: \(targetPID)")
|
||||
} else if let pidInt = Int(appID), let runningApp = NSRunningApplication(processIdentifier: pid_t(pidInt)) {
|
||||
targetPID = runningApp.processIdentifier // Validates PID and gets consistent pid_t
|
||||
axDebugLog("getElementAtPoint: Using PID from appIdentifier string '\(appID)': \(targetPID)")
|
||||
} else {
|
||||
// Fallback to finding app by name (less reliable)
|
||||
let apps = NSWorkspace.shared.runningApplications.filter { $0.localizedName?.localizedCaseInsensitiveContains(appID) == true || $0.bundleIdentifier == appID }
|
||||
if let firstMatch = apps.first {
|
||||
targetPID = firstMatch.processIdentifier
|
||||
axDebugLog("getElementAtPoint: Found app by name/fallback '\(appID)', PID: \(targetPID)")
|
||||
} else {
|
||||
let errorMsg = "getElementAtPoint: Could not determine PID from appIdentifier: \(appID)"
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
}
|
||||
} else { // No explicitPID and no appIdentifierOrNil, try focused app
|
||||
guard let focusedApp = NSWorkspace.shared.frontmostApplication else {
|
||||
let errorMsg = "getElementAtPoint: No PID or appIdentifier provided, and could not get focused application."
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
targetPID = focusedApp.processIdentifier
|
||||
axDebugLog("getElementAtPoint: No PID/appIdentifier, using focused app PID: \(targetPID) (\(focusedApp.localizedName ?? "Unknown"))")
|
||||
}
|
||||
|
||||
if targetPID == 0 { // Still no valid PID
|
||||
let errorMsg = "getElementAtPoint: Failed to resolve a valid application PID."
|
||||
axErrorLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
|
||||
let systemWideAppElement = AXUIElementCreateApplication(targetPID)
|
||||
appElementForPath = Element(systemWideAppElement) // For path generation later
|
||||
var finalY = Float(point.y)
|
||||
|
||||
if !isScreenCoordinatesTopLeft {
|
||||
guard let mainScreen = NSScreen.main else {
|
||||
let errorMsg = "getElementAtPoint: Cannot get main screen info for coordinate conversion."
|
||||
axWarningLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
let screenHeight = Float(mainScreen.frame.height)
|
||||
finalY = screenHeight - Float(point.y)
|
||||
axDebugLog("getElementAtPoint: Converted point from (\(point.x), \(point.y)) to (\(point.x), \(finalY)) for AX top-left system.")
|
||||
} else {
|
||||
axDebugLog("getElementAtPoint: Using provided point (\(point.x), \(finalY)) as top-left screen coordinates.")
|
||||
}
|
||||
|
||||
var hitTestElementRef: AXUIElement?
|
||||
let error = AXUIElementCopyElementAtPosition(systemWideAppElement, Float(point.x), finalY, &hitTestElementRef)
|
||||
|
||||
if error == .success, let rawElement = hitTestElementRef {
|
||||
let foundElement = Element(rawElement)
|
||||
axDebugLog("getElementAtPoint: Successfully found element at (\(Float(point.x))), \(finalY)). Element: \(foundElement.briefDescription())")
|
||||
|
||||
// Call the global getElementAttributes function
|
||||
let (attributes, _) = await getElementAttributes(
|
||||
element: foundElement,
|
||||
attributes: requestedAttributes ?? AXorcist.defaultAttributesToFetch,
|
||||
outputFormat: .jsonString, // Using jsonString format
|
||||
valueFormatOption: .smart // Assuming default options
|
||||
)
|
||||
let pathArray = appElementForPath != nil ? foundElement.generatePathArray(upTo: appElementForPath!) : foundElement.generatePathArray()
|
||||
let axElement = AXElement(attributes: attributes, path: pathArray)
|
||||
return HandlerResponse(data: AnyCodable(axElement))
|
||||
} else {
|
||||
let errorMsg = "getElementAtPoint: No element found at (\(Float(point.x))), \(finalY)). Error: \(error.rawValue)"
|
||||
axDebugLog(errorMsg)
|
||||
return HandlerResponse(error: errorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,19 @@ public enum AXLogLevel: String, Codable, Sendable, CaseIterable {
|
||||
case critical // For errors that might lead to a crash or critical malfunction
|
||||
}
|
||||
|
||||
// Added AXLogDetailLevel
|
||||
public enum AXLogDetailLevel: String, Codable, Sendable, CaseIterable {
|
||||
case minimal // Only critical/error messages
|
||||
case normal // Info, warning, error, critical
|
||||
case verbose // Debug, info, warning, error, critical (all messages)
|
||||
}
|
||||
|
||||
// Added AXLogOutputFormat
|
||||
public enum AXLogOutputFormat: String, Codable, Sendable, CaseIterable {
|
||||
case text
|
||||
case json
|
||||
}
|
||||
|
||||
public struct AXLogEntry: Codable, Sendable, Identifiable {
|
||||
public let id: UUID
|
||||
public let timestamp: Date
|
||||
@ -16,7 +29,7 @@ public struct AXLogEntry: Codable, Sendable, Identifiable {
|
||||
public let file: String?
|
||||
public let function: String?
|
||||
public let line: Int?
|
||||
public let details: [String: String]? // Optional dictionary for structured details
|
||||
public let details: [String: AnyCodable]? // Changed to AnyCodable
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
@ -26,7 +39,7 @@ public struct AXLogEntry: Codable, Sendable, Identifiable {
|
||||
file: String? = #file,
|
||||
function: String? = #function,
|
||||
line: Int? = #line,
|
||||
details: [String: String]? = nil
|
||||
details: [String: AnyCodable]? = nil // Changed to AnyCodable
|
||||
) {
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
@ -71,7 +84,19 @@ extension AXLogEntry {
|
||||
logParts.append("- \(message)")
|
||||
|
||||
if let details = details, !details.isEmpty {
|
||||
logParts.append("Details: \(details.map { key, value in "\(key): \(value)" }.joined(separator: ", "))")
|
||||
// Simplified details formatting for AnyCodable
|
||||
let detailString = details.map { key, value in
|
||||
let valueStr: String
|
||||
if let v = value.value as? String {
|
||||
valueStr = v
|
||||
} else if let v = value.value as? CustomStringConvertible {
|
||||
valueStr = v.description
|
||||
} else {
|
||||
valueStr = String(describing: value.value)
|
||||
}
|
||||
return "\(key): \(valueStr)"
|
||||
}.joined(separator: ", ")
|
||||
logParts.append("Details: [\(detailString)]")
|
||||
}
|
||||
|
||||
return logParts.joined(separator: " ")
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// Extension to GlobalAXLogger for additional functionality needed by the command system
|
||||
|
||||
extension GlobalAXLogger {
|
||||
// Properties for tracking state
|
||||
private static var _isLoggingEnabled: Bool = false
|
||||
private static var _detailLevel: AXLogDetailLevel = .normal
|
||||
private static var _currentCommandID: String?
|
||||
private static var _currentAppName: String?
|
||||
|
||||
// MARK: - Logging Control
|
||||
|
||||
public func isLoggingEnabled() async -> Bool {
|
||||
return Self._isLoggingEnabled
|
||||
}
|
||||
|
||||
public func setLoggingEnabled(_ enabled: Bool) async {
|
||||
Self._isLoggingEnabled = enabled
|
||||
}
|
||||
|
||||
public func getDetailLevel() async -> AXLogDetailLevel {
|
||||
return Self._detailLevel
|
||||
}
|
||||
|
||||
public func setDetailLevel(_ level: AXLogDetailLevel) async {
|
||||
Self._detailLevel = level
|
||||
}
|
||||
|
||||
// MARK: - Operation Context
|
||||
|
||||
public func updateOperationDetails(commandID: String?, appName: String?) async {
|
||||
Self._currentCommandID = commandID
|
||||
Self._currentAppName = appName
|
||||
}
|
||||
|
||||
// MARK: - Log Formatting
|
||||
|
||||
public func getLogsAsStrings(
|
||||
format: AXLogOutputFormat,
|
||||
includeTimestamps: Bool = true,
|
||||
includeLevels: Bool = true,
|
||||
includeDetails: Bool = false,
|
||||
includeAppName: Bool = false,
|
||||
includeCommandID: Bool = false
|
||||
) async -> [String] {
|
||||
let entries = GlobalAXLogger.shared.getEntries()
|
||||
|
||||
return entries.map { entry in
|
||||
var components: [String] = []
|
||||
|
||||
if includeTimestamps {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
|
||||
components.append("[\(formatter.string(from: entry.timestamp))]")
|
||||
}
|
||||
|
||||
if includeLevels {
|
||||
components.append("[\(entry.level.rawValue.uppercased())]")
|
||||
}
|
||||
|
||||
if includeCommandID, let commandID = Self._currentCommandID {
|
||||
components.append("[CMD:\(commandID)]")
|
||||
}
|
||||
|
||||
if includeAppName, let appName = Self._currentAppName {
|
||||
components.append("[APP:\(appName)]")
|
||||
}
|
||||
|
||||
components.append(entry.message)
|
||||
|
||||
if includeDetails, let details = entry.details, !details.isEmpty {
|
||||
let detailsStr = details.map { "\($0.key)=\($0.value)" }.joined(separator: ", ")
|
||||
components.append("{\(detailsStr)}")
|
||||
}
|
||||
|
||||
return components.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
internal func getLogEntriesAsJSON() async throws -> String {
|
||||
let entries = GlobalAXLogger.shared.getEntries()
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let jsonData = try encoder.encode(entries)
|
||||
return String(data: jsonData, encoding: .utf8) ?? "[]"
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
public enum AXLogDetailLevel: String, Codable {
|
||||
case minimal
|
||||
case normal
|
||||
case verbose
|
||||
}
|
||||
|
||||
// MARK: - Log Output Format
|
||||
|
||||
public enum AXLogOutputFormat: String, Codable {
|
||||
case text
|
||||
case json
|
||||
}
|
||||
@ -20,6 +20,9 @@ public class GlobalAXLogger {
|
||||
// Callers must ensure main-thread execution for all logger interactions.
|
||||
|
||||
public var isJSONLoggingEnabled: Bool = false // Direct access, assuming main-thread safety
|
||||
// Instance properties for logging control, moved from extension
|
||||
public var isLoggingEnabled: Bool = false
|
||||
public var detailLevel: AXLogDetailLevel = .normal
|
||||
|
||||
private init() {
|
||||
if let envVar = ProcessInfo.processInfo.environment["AXORC_JSON_LOG_ENABLED"], envVar.lowercased() == "true" {
|
||||
@ -31,6 +34,13 @@ public class GlobalAXLogger {
|
||||
// MARK: - Logging Core
|
||||
// Assumes this method is always called on the main thread.
|
||||
public func log(_ entry: AXLogEntry) {
|
||||
guard self.isLoggingEnabled else { return }
|
||||
// Use fully qualified enum cases
|
||||
if entry.level == .debug && self.detailLevel != AXLogDetailLevel.verbose {
|
||||
if self.detailLevel == AXLogDetailLevel.minimal { return }
|
||||
if self.detailLevel == AXLogDetailLevel.normal && entry.level == .debug { return }
|
||||
}
|
||||
|
||||
let condensedMessage: String = {
|
||||
if entry.message.count > maxMessageLength {
|
||||
let prefix = entry.message.prefix(maxMessageLength)
|
||||
@ -128,27 +138,27 @@ public class GlobalAXLogger {
|
||||
// MARK: - Global Logging Functions (Convenience Wrappers)
|
||||
// These are synchronous and assume GlobalAXLogger.shared.log is safe to call directly (i.e., from main thread).
|
||||
|
||||
public func axDebugLog(_ message: String, details: [String: String]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
public func axDebugLog(_ message: String, details: [String: AnyCodable]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
let entry = AXLogEntry(level: .debug, message: message, file: file, function: function, line: line, details: details)
|
||||
GlobalAXLogger.shared.log(entry)
|
||||
}
|
||||
|
||||
public func axInfoLog(_ message: String, details: [String: String]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
public func axInfoLog(_ message: String, details: [String: AnyCodable]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
let entry = AXLogEntry(level: .info, message: message, file: file, function: function, line: line, details: details)
|
||||
GlobalAXLogger.shared.log(entry)
|
||||
}
|
||||
|
||||
public func axWarningLog(_ message: String, details: [String: String]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
public func axWarningLog(_ message: String, details: [String: AnyCodable]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
let entry = AXLogEntry(level: .warning, message: message, file: file, function: function, line: line, details: details)
|
||||
GlobalAXLogger.shared.log(entry)
|
||||
}
|
||||
|
||||
public func axErrorLog(_ message: String, details: [String: String]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
public func axErrorLog(_ message: String, details: [String: AnyCodable]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
let entry = AXLogEntry(level: .error, message: message, file: file, function: function, line: line, details: details)
|
||||
GlobalAXLogger.shared.log(entry)
|
||||
}
|
||||
|
||||
public func axFatalLog(_ message: String, details: [String: String]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
public func axFatalLog(_ message: String, details: [String: AnyCodable]? = nil, file: String = #file, function: String = #function, line: Int = #line) {
|
||||
let entry = AXLogEntry(level: .critical, message: message, file: file, function: function, line: line, details: details)
|
||||
GlobalAXLogger.shared.log(entry)
|
||||
}
|
||||
|
||||
@ -48,6 +48,21 @@ extension HandlerResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AXResponse Integration
|
||||
|
||||
extension HandlerResponse {
|
||||
/// Creates a HandlerResponse from an AXResponse
|
||||
/// - Parameter axResponse: The AXResponse to convert
|
||||
public init(from axResponse: AXResponse) {
|
||||
switch axResponse {
|
||||
case .success(let payload, _):
|
||||
self.init(data: payload, error: nil)
|
||||
case .error(let message, _, _):
|
||||
self.init(data: nil, error: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Structure for Detailed Errors (Example)
|
||||
|
||||
/// An example structure for providing more detailed error information if needed.
|
||||
|
||||
@ -9,6 +9,9 @@ public struct JSONPathHintComponent: Codable, Sendable {
|
||||
case exact
|
||||
case contains
|
||||
case regex
|
||||
case containsAny
|
||||
case prefix
|
||||
case suffix
|
||||
}
|
||||
|
||||
/// The type of attribute to match (e.g., "ROLE", "TITLE", "DOM", "DOMCLASS"). Case-insensitive.
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
// GlobalAXLogger is assumed available
|
||||
import Logging
|
||||
// GlobalAXLogger, AXMiscConstants, JSONPathHintComponent are assumed available.
|
||||
|
||||
// JSONPathHintComponent is in Models/JSONPathHintComponent.swift
|
||||
// Added logger definition
|
||||
private let logger = Logger(label: "AXorcist.ElementSearch")
|
||||
|
||||
// MARK: - Main Element Finding Orchestration
|
||||
|
||||
@ -17,308 +19,292 @@ public func findTargetElement(
|
||||
for appIdentifier: String,
|
||||
locator: Locator,
|
||||
maxDepthForSearch: Int
|
||||
) async -> (element: Element?, error: String?) {
|
||||
) -> (element: Element?, error: String?) {
|
||||
|
||||
let pathHintDebugString = locator.rootElementPathHint?.map { component in "(attr: \\(component.attribute), val: \\(component.value), depth: \\(component.depth ?? -1))" }.joined(separator: "; ") ?? "nil"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message:
|
||||
"[findTargetElement ENTRY] App=\\(appIdentifier), Locator: criteria=\(locator.criteria), " +
|
||||
"jsonPathHint=[\\(pathHintDebugString)]"
|
||||
))
|
||||
|
||||
guard let appElement = applicationElement(for: appIdentifier) else {
|
||||
let msg = "Application not found: \\(appIdentifier)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: msg))
|
||||
return (nil, msg)
|
||||
}
|
||||
|
||||
let jsonPathComponents = locator.rootElementPathHint ?? []
|
||||
let hasOnlyAppSpecificCriteria = locator.criteria.allSatisfy { criterion in
|
||||
let key = criterion.attribute.lowercased()
|
||||
return key == "pid" || key == "bundleid" || key == "appname"
|
||||
}
|
||||
|
||||
if !jsonPathComponents.isEmpty && hasOnlyAppSpecificCriteria {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "findTargetElement: Using JSONPathHint primarily as criteria are app-specific or empty."))
|
||||
if let elementFromPath = await navigateToElementByJSONPathHint(
|
||||
pathHint: jsonPathComponents,
|
||||
initialSearchElement: appElement
|
||||
) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "findTargetElement: Found element directly via JSONPathHint: \\(elementFromPath.briefDescription(option: .smart))"))
|
||||
if let descCrit = locator.descendantCriteria, !descCrit.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "findTargetElement: Performing descendantCriteria search. DescCriteria: \\(descCrit)"))
|
||||
// Convert descendantCriteria dictionary to [Criterion]
|
||||
let descCriteria = descCrit.map { key, value in
|
||||
Criterion(attribute: key, value: value, match_type: nil)
|
||||
}
|
||||
let descLocator = Locator(criteria: descCriteria)
|
||||
if let descendant = await traverseAndSearch(currentElement: elementFromPath,
|
||||
locator: descLocator,
|
||||
maxDepth: maxDepthForSearch) {
|
||||
return (descendant, nil)
|
||||
} else {
|
||||
return (nil, "Element found by path hint, but descendant criteria did not match.")
|
||||
}
|
||||
}
|
||||
return (elementFromPath, nil)
|
||||
} else {
|
||||
let msg = "Element not found via JSONPathHint: [\\(pathHintDebugString)]"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: msg))
|
||||
return (nil, msg)
|
||||
}
|
||||
}
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "findTargetElement: Proceeding with criteria-based search (JSONPathHint may refine start)."))
|
||||
if let foundElement = await findElementViaCriteriaAndJSONPathHint(
|
||||
application: appElement,
|
||||
locator: locator,
|
||||
maxDepth: maxDepthForSearch
|
||||
) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "findTargetElement: Found via criteria (and/or JSONPathHint): \\(foundElement.briefDescription(option: .smart))"))
|
||||
var baseElement = foundElement
|
||||
if let descCrit = locator.descendantCriteria, !descCrit.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "findTargetElement: Performing descendantCriteria search. DescCriteria: \\(descCrit)"))
|
||||
// Convert descendantCriteria dictionary to [Criterion]
|
||||
let descCriteria = descCrit.map { key, value in
|
||||
Criterion(attribute: key, value: value, match_type: nil)
|
||||
}
|
||||
let descLoc = Locator(criteria: descCriteria)
|
||||
if let descendant = await traverseAndSearch(currentElement: baseElement,
|
||||
locator: descLoc,
|
||||
maxDepth: maxDepthForSearch) {
|
||||
baseElement = descendant
|
||||
} else {
|
||||
let msg = "Descendant element not found matching: \\(descCrit)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: msg))
|
||||
return (nil, msg)
|
||||
}
|
||||
}
|
||||
return (baseElement, nil)
|
||||
let pathHintDebugString = locator.rootElementPathHint?.map { $0.descriptionForLog() }.joined(separator: "\n -> ") ?? "nil"
|
||||
let criteriaDebugString = locator.criteria.map { criterion in "[\(criterion.attribute):\(criterion.value), match:\(criterion.matchType?.rawValue ?? "exact")]" }.joined(separator: ", ")
|
||||
|
||||
// Use criteriaDebugString in the log message
|
||||
var logMessage = """
|
||||
FindTargetEl: START
|
||||
App: '\(appIdentifier)'
|
||||
MaxDepth: \(maxDepthForSearch)
|
||||
"""
|
||||
if !criteriaDebugString.isEmpty {
|
||||
logMessage += "\n Initial Criteria: \(criteriaDebugString)"
|
||||
} else {
|
||||
let msg = "Element not found matching criteria: \\(locator.criteria)"
|
||||
if !jsonPathComponents.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "\\(msg) (JSONPathHint was: [\\(pathHintDebugString)])"))
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: msg))
|
||||
}
|
||||
return (nil, msg)
|
||||
logMessage += "\n Initial Criteria: none"
|
||||
}
|
||||
}
|
||||
logMessage += "\n PathHint (count: \(locator.rootElementPathHint?.count ?? 0)):\n -> \(pathHintDebugString)"
|
||||
logger.info("\(logMessage)")
|
||||
|
||||
// MARK: - Core Search Logic
|
||||
guard let appElement = getApplicationElement(for: appIdentifier) else {
|
||||
logger.error("FindTargetEl: Could not get application element for \(appIdentifier)")
|
||||
return (nil, "Application not found or not accessible: \(appIdentifier)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
internal func navigateToElementByJSONPathHint(pathHint: [JSONPathHintComponent], initialSearchElement: Element) async -> Element? {
|
||||
var currentElementInPath = initialSearchElement
|
||||
let pathHintDesc = pathHint.map { component in "(attr:\\(component.attribute),val:\\(component.value),d:\\(component.depth ?? -1))" }.joined(separator: " -> ")
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message:
|
||||
"navigateToElementByJSONPathHint: Starting with \\(pathHint.count) JSON components [\\(pathHintDesc)] from " +
|
||||
"\\(initialSearchElement.briefDescription(option: .smart))"
|
||||
))
|
||||
var currentSearchElement = appElement
|
||||
var searchStartingPointDescription = "application root \(appElement.briefDescription(option: .smart))"
|
||||
|
||||
for (index, component) in pathHint.enumerated() {
|
||||
guard let componentCriteria = component.simpleCriteria else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "navigateToElementByJSONPathHint: Skipping component #\\(index) (attr:\\(component.attribute)) due to invalid/unresolved attribute type. Path broken."))
|
||||
return nil
|
||||
// 1. Navigate by pathHint if provided
|
||||
if let jsonPathComponents = locator.rootElementPathHint, !jsonPathComponents.isEmpty {
|
||||
logger.debug("FindTargetEl: Path hint provided with \(jsonPathComponents.count) components. Navigating path first from \(searchStartingPointDescription).")
|
||||
|
||||
// Convert [JSONPathHintComponent] to [PathStep]
|
||||
let pathSteps: [PathStep] = jsonPathComponents.map { component in
|
||||
let criterion = Criterion(attribute: component.attribute, value: component.value, matchType: component.matchType)
|
||||
return PathStep(criteria: [criterion], matchType: component.matchType, matchAllCriteria: true, maxDepthForStep: component.depth)
|
||||
}
|
||||
let depthForThisStep = component.depth ?? JSONPathHintComponent.defaultDepthForSegment
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message:
|
||||
"navigateToElementByJSONPathHint: Processing component #\\(index) {attr:\"\\(component.attribute)\", val:\"\\(component.value)\", depth:\\(depthForThisStep)} " +
|
||||
"starting from \\(currentElementInPath.briefDescription(option: .raw))."
|
||||
))
|
||||
|
||||
if let foundElementForThisStep = await findDescendantMatchingCriteria(
|
||||
startElement: currentElementInPath,
|
||||
criteria: componentCriteria,
|
||||
matchType: component.matchType ?? .exact,
|
||||
maxDepthForThisHintStep: depthForThisStep
|
||||
|
||||
if let navigatedElement = findDescendantAtPath(
|
||||
currentRoot: currentSearchElement,
|
||||
pathComponents: pathSteps, // Use converted pathSteps
|
||||
maxDepth: maxDepthForSearch, // Path navigation steps might need their own depth concept or use overall
|
||||
debugSearch: locator.debugPathSearch ?? false
|
||||
) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "navigateToElementByJSONPathHint: Matched component #\\(index). Found: \\(foundElementForThisStep.briefDescription(option: .raw)). Advancing."))
|
||||
currentElementInPath = foundElementForThisStep
|
||||
logger.info("FindTargetEl: Path navigation successful. New search root: \(navigatedElement.briefDescription(option: ValueFormatOption.smart))")
|
||||
currentSearchElement = navigatedElement
|
||||
searchStartingPointDescription = "navigated path element \(currentSearchElement.briefDescription(option: ValueFormatOption.smart))"
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "navigateToElementByJSONPathHint: Failed component #\\(index) {attr:\"\\(component.attribute)\", val:\"\\(component.value)\"} from \\(currentElementInPath.briefDescription(option: .raw)) depth \\(depthForThisStep). Path broken."))
|
||||
return nil
|
||||
let pathFailedError = "FindTargetEl: Path navigation failed. Could not find element at specified path hint: [\(pathHintDebugString)]"
|
||||
logger.warning("\(pathFailedError)")
|
||||
return (nil, pathFailedError)
|
||||
}
|
||||
} else {
|
||||
logger.debug("FindTargetEl: No path hint provided, or path hint was empty. Searching from \(searchStartingPointDescription).")
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "navigateToElementByJSONPathHint: Successfully navigated all \\(pathHint.count) JSON components. Final element: \\(currentElementInPath.briefDescription(option: .smart))"))
|
||||
return currentElementInPath
|
||||
}
|
||||
|
||||
@MainActor
|
||||
internal func findDescendantMatchingCriteria(startElement: Element, criteria: [String: String], matchType: JSONPathHintComponent.MatchType, maxDepthForThisHintStep: Int) async -> Element? {
|
||||
guard !criteria.isEmpty else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "findDescendantMatchingCriteria: Called with empty criteria."))
|
||||
return nil
|
||||
}
|
||||
// Convert dictionary criteria to [Criterion]
|
||||
let criterionArray = criteria.map { key, value in
|
||||
Criterion(attribute: key, value: value, match_type: nil)
|
||||
}
|
||||
let tempLocator = Locator(criteria: criterionArray)
|
||||
return await traverseAndSearch(currentElement: startElement, locator: tempLocator, maxDepth: maxDepthForThisHintStep)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
internal func traverseAndSearch(currentElement: Element, locator: Locator, maxDepth: Int) async -> Element? {
|
||||
// 2. After path navigation (or if no path), apply final criteria from locator.criteria
|
||||
// If locator.criteria is empty, it means the path navigation itself was meant to find the target.
|
||||
if locator.criteria.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "traverseAndSearch: Called with empty criteria. Returning current element: \\(currentElement.briefDescription(option: .smart))"))
|
||||
return currentElement
|
||||
if locator.rootElementPathHint?.isEmpty ?? true {
|
||||
let noCriteriaError = "FindTargetEl: No criteria provided in locator and no path hint. Cannot perform search."
|
||||
logger.error("\(noCriteriaError)")
|
||||
return (nil, noCriteriaError)
|
||||
}
|
||||
logger.info("FindTargetEl: Path hint was used and no further criteria specified. Returning element found at path: \(currentSearchElement.briefDescription(option: .smart))")
|
||||
return (currentSearchElement, nil)
|
||||
}
|
||||
|
||||
let visitor = SearchVisitor(locator: locator)
|
||||
var traverser = TreeTraverser()
|
||||
var traversalState = TraversalState(maxDepth: maxDepth, startElement: currentElement)
|
||||
return await traverser.traverse(from: currentElement, visitor: visitor, state: &traversalState)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
internal func processPathHintAndDetermineStartElement(application: Element, locator: Locator) async -> Element {
|
||||
guard let jsonPathComponents = locator.rootElementPathHint, !jsonPathComponents.isEmpty else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "processPathHint: No rootElementPathHint (JSON) provided or empty. Searching from app root."))
|
||||
return application
|
||||
}
|
||||
let pathHintDebug = jsonPathComponents.map { component in "(attr:\\(component.attribute),val:\\(component.value),d:\\(component.depth ?? -1))" }.joined(separator: " -> ")
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "processPathHint: Starting JSON path hint navigation [\\(pathHintDebug)] for search root. \\(jsonPathComponents.count) components."))
|
||||
if let elementFromPathHint = await navigateToElementByJSONPathHint(
|
||||
pathHint: jsonPathComponents,
|
||||
initialSearchElement: application
|
||||
) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "processPathHint: JSON path hint navigation successful. New search start: \\(elementFromPathHint.briefDescription(option: .smart))."))
|
||||
return elementFromPathHint
|
||||
|
||||
logger.debug("FindTargetEl: Applying final criteria from locator (\(locator.criteria.count) criteria) starting from \(searchStartingPointDescription). MatchAll=\(locator.matchAll ?? true), MatchType=\(locator.criteria.first?.matchType?.rawValue ?? "default/exact")")
|
||||
|
||||
// Use matchAll and matchType from the main Locator object for these final criteria, if they exist there.
|
||||
// Otherwise, SearchVisitor will use its defaults or what's on individual Criterion objects.
|
||||
let finalSearchMatchType = locator.criteria.first?.matchType ?? .exact // Simplified: take from first criterion or default
|
||||
let finalSearchMatchAll = locator.matchAll ?? true
|
||||
|
||||
let searchVisitor = SearchVisitor(
|
||||
criteria: locator.criteria,
|
||||
matchType: finalSearchMatchType,
|
||||
matchAllCriteria: finalSearchMatchAll,
|
||||
stopAtFirstMatch: true, // For the final search, we typically want the first match.
|
||||
maxDepth: maxDepthForSearch
|
||||
)
|
||||
|
||||
traverseAndSearch(element: currentSearchElement, visitor: searchVisitor, currentDepth: 0, maxDepth: maxDepthForSearch)
|
||||
|
||||
if let foundMatch = searchVisitor.foundElement { // Changed from foundElements.first
|
||||
logger.info("FindTargetEl: Found final descendant matching criteria: \(foundMatch.briefDescription(option: .smart))")
|
||||
return (foundMatch, nil)
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "processPathHint: JSON path hint navigation failed [\\(pathHintDebug)]. Full search will be from app root."))
|
||||
return application
|
||||
let criteriaDesc = locator.criteria.map { "\($0.attribute):\($0.value)" }.joined(separator: ", ")
|
||||
let finalSearchError = "FindTargetEl: No element found matching final criteria [\(criteriaDesc)] starting from \(searchStartingPointDescription)."
|
||||
logger.warning("\(finalSearchError)")
|
||||
return (nil, finalSearchError)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
internal func findElementViaCriteriaAndJSONPathHint(
|
||||
application: Element,
|
||||
locator: Locator,
|
||||
maxDepth: Int
|
||||
) async -> Element? {
|
||||
let pathHintForLog = locator.rootElementPathHint?.map { component in "(attr:\\(component.attribute),val:\\(component.value),d:\\(component.depth ?? -1))" }.joined(separator: " -> ") ?? "nil"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message:
|
||||
"[findElementViaCriteriaAndJSONPathHint ENTRY] AppPID: \\(application.pid() ?? -1), Locator.criteria: \\(locator.criteria), " +
|
||||
"JSONPathHint: [\\(pathHintForLog)]"
|
||||
))
|
||||
|
||||
let searchStartElement = await processPathHintAndDetermineStartElement(application: application, locator: locator)
|
||||
if locator.criteria.isEmpty && (locator.rootElementPathHint != nil && !locator.rootElementPathHint!.isEmpty) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "[findElementViaCriteriaAndJSONPathHint] Criteria empty, JSON path hint used. Returning: \\(searchStartElement.briefDescription(option: .smart))"))
|
||||
return searchStartElement
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "[findElementViaCriteriaAndJSONPathHint] Search start: \\(searchStartElement.briefDescription(option: .smart)). Applying criteria: \\(locator.criteria)"))
|
||||
return await traverseAndSearch(
|
||||
currentElement: searchStartElement,
|
||||
locator: locator,
|
||||
maxDepth: maxDepth
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Element Collection
|
||||
// MARK: - Element Collection Logic
|
||||
|
||||
@MainActor
|
||||
internal func collectAllElements(
|
||||
public func collectAllElements(
|
||||
from startElement: Element,
|
||||
matching locator: Locator,
|
||||
appElementForContext: Element,
|
||||
attributesToFetch: [String],
|
||||
outputFormat: OutputFormat,
|
||||
maxElements: Int?,
|
||||
maxSearchDepth: Int
|
||||
) async -> [AXElementData] {
|
||||
let effectiveFilterCriteria = locator.criteria
|
||||
|
||||
let visitor = CollectAllVisitor(
|
||||
attributesToFetch: attributesToFetch,
|
||||
outputFormat: outputFormat,
|
||||
appElement: appElementForContext,
|
||||
filterCriteria: effectiveFilterCriteria
|
||||
)
|
||||
var traverser = TreeTraverser()
|
||||
var traversalState = TraversalState(maxDepth: maxSearchDepth, startElement: startElement)
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "CollectAll: Starting from \\(startElement.briefDescription(option: .smart)), FilterCriteria: \\(effectiveFilterCriteria), MaxDepth: \\(maxSearchDepth)"))
|
||||
matching criteria: [Criterion]? = nil,
|
||||
maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch,
|
||||
includeIgnored: Bool = false
|
||||
) -> [Element] {
|
||||
let criteriaDebugString = criteria?.map { "\($0.attribute):\($0.value)(\($0.match_type?.rawValue ?? "exact"))" }.joined(separator: ", ") ?? "all"
|
||||
logger.info("CollectAll: From [\(startElement.briefDescription(option: ValueFormatOption.smart))], Criteria: [\(criteriaDebugString)], MaxDepth: \(maxDepth), Ignored: \(includeIgnored)")
|
||||
|
||||
_ = await traverser.traverse(from: startElement, visitor: visitor, state: &traversalState)
|
||||
let visitor = CollectAllVisitor(criteria: criteria, includeIgnored: includeIgnored)
|
||||
traverseAndSearch(element: startElement, visitor: visitor, currentDepth: 0, maxDepth: maxDepth)
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "CollectAll: Visitor collected \\(visitor.collectedElements.count) AXElementData items."))
|
||||
|
||||
if let maxEl = maxElements, visitor.collectedElements.count > maxEl {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "CollectAll: Truncating to \\(maxEl) elements."))
|
||||
return Array(visitor.collectedElements.prefix(maxEl))
|
||||
}
|
||||
logger.info("CollectAll: Found \(visitor.collectedElements.count) elements.")
|
||||
return visitor.collectedElements
|
||||
}
|
||||
|
||||
enum ElementMatchStatus {
|
||||
case fullMatch
|
||||
case partialMatchActionMissing
|
||||
case noMatch
|
||||
// MARK: - Generic Tree Traversal with Visitor
|
||||
|
||||
// Protocol for visitors used in tree traversal
|
||||
@MainActor
|
||||
public protocol ElementVisitor {
|
||||
// If visit returns .stop, traversal stops. If .skipChildren, children of current element are not visited.
|
||||
// Otherwise, traversal continues (.continue).
|
||||
func visit(element: Element, depth: Int) -> TreeVisitorResult
|
||||
}
|
||||
|
||||
public enum TreeVisitorResult {
|
||||
case `continue`
|
||||
case skipChildren
|
||||
case stop
|
||||
}
|
||||
|
||||
@MainActor
|
||||
internal func evaluateElementAgainstCriteria(
|
||||
public func traverseAndSearch(
|
||||
element: Element,
|
||||
locator: Locator,
|
||||
actionToVerify: String?,
|
||||
depth: Int
|
||||
) async -> ElementMatchStatus {
|
||||
if await !elementMatchesCriteria(element, criteria: locator.criteria) {
|
||||
return .noMatch
|
||||
visitor: ElementVisitor,
|
||||
currentDepth: Int,
|
||||
maxDepth: Int
|
||||
) {
|
||||
if currentDepth > maxDepth {
|
||||
logger.debug("Traverse: Max depth \(maxDepth) reached at [\(element.briefDescription(option: ValueFormatOption.smart))]. Stopping this branch.")
|
||||
return
|
||||
}
|
||||
|
||||
if let actionName = actionToVerify, !actionName.isEmpty {
|
||||
if element.isActionSupported(actionName) {
|
||||
return .fullMatch
|
||||
} else {
|
||||
return .partialMatchActionMissing
|
||||
let visitResult = visitor.visit(element: element, depth: currentDepth)
|
||||
|
||||
switch visitResult {
|
||||
case .stop:
|
||||
logger.debug("Traverse: Visitor requested STOP at [\(element.briefDescription(option: ValueFormatOption.smart))] depth \(currentDepth).")
|
||||
return
|
||||
case .skipChildren:
|
||||
logger.debug("Traverse: Visitor requested SKIP_CHILDREN at [\(element.briefDescription(option: ValueFormatOption.smart))] depth \(currentDepth).")
|
||||
return // Do not process children
|
||||
case .continue:
|
||||
logger.debug("Traverse: Visitor requested CONTINUE at [\(element.briefDescription(option: ValueFormatOption.smart))] depth \(currentDepth). Processing children.")
|
||||
// Continue to process children
|
||||
break
|
||||
}
|
||||
|
||||
if let children = element.children() {
|
||||
for child in children {
|
||||
traverseAndSearch(element: child, visitor: visitor, currentDepth: currentDepth + 1, maxDepth: maxDepth)
|
||||
// If the visitor is a SearchVisitor that stops at first match, check if it found something.
|
||||
if let searchVisitor = visitor as? SearchVisitor, searchVisitor.stopAtFirstMatchInternal, searchVisitor.foundElement != nil {
|
||||
logger.debug("Traverse: SearchVisitor found match and stopAtFirstMatch is true. Stopping traversal early.")
|
||||
return // Stop traversal early
|
||||
}
|
||||
}
|
||||
}
|
||||
return .fullMatch
|
||||
}
|
||||
|
||||
// MARK: - Search Visitor Implementation
|
||||
|
||||
@MainActor
|
||||
public func search(element: Element,
|
||||
locator: Locator,
|
||||
requireAction: String?,
|
||||
depth: Int = 0,
|
||||
maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch
|
||||
) async -> Element? {
|
||||
var traverser = TreeTraverser()
|
||||
let visitor = SearchVisitor(locator: locator, requireAction: requireAction)
|
||||
var state = TraversalState(maxDepth: maxDepth, startElement: element)
|
||||
let result = await traverser.traverse(from: element, visitor: visitor, state: &state)
|
||||
return result
|
||||
public class SearchVisitor: ElementVisitor {
|
||||
public var foundElement: Element? // Stores the first element that matches criteria
|
||||
public var allFoundElements: [Element] = [] // Stores all elements that match criteria
|
||||
private let criteria: [Criterion]
|
||||
internal let stopAtFirstMatchInternal: Bool
|
||||
private let maxDepth: Int
|
||||
private var currentMaxDepthReachedByVisitor: Int = 0
|
||||
private let matchType: JSONPathHintComponent.MatchType // Added
|
||||
private let matchAllCriteriaBool: Bool // Added (renamed to avoid conflict with func name)
|
||||
|
||||
init(
|
||||
criteria: [Criterion],
|
||||
matchType: JSONPathHintComponent.MatchType = .exact, // Added with default
|
||||
matchAllCriteria: Bool = true, // Added with default
|
||||
stopAtFirstMatch: Bool = false,
|
||||
maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch
|
||||
) {
|
||||
self.criteria = criteria
|
||||
self.matchType = matchType // Store
|
||||
self.matchAllCriteriaBool = matchAllCriteria // Store
|
||||
self.stopAtFirstMatchInternal = stopAtFirstMatch
|
||||
self.maxDepth = maxDepth
|
||||
logger.debug("SearchVisitor Init: Criteria: \(criteria.map { "\($0.attribute):\($0.value)(\($0.match_type?.rawValue ?? "exact"))" }.joined(separator: ", ")), StopAtFirst: \(stopAtFirstMatchInternal), MaxDepth: \(maxDepth), MatchType: \(matchType), MatchAll: \(matchAllCriteria)")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func visit(element: Element, depth: Int) -> TreeVisitorResult {
|
||||
currentMaxDepthReachedByVisitor = max(currentMaxDepthReachedByVisitor, depth)
|
||||
if depth > maxDepth {
|
||||
logger.debug("SearchVisitor: Max depth \(maxDepth) reached internally at [\(element.briefDescription(option: ValueFormatOption.smart))]. Skipping.")
|
||||
return .skipChildren // Or .stop, depending on desired behavior beyond maxDepth by visitor
|
||||
}
|
||||
|
||||
let elementDesc = element.briefDescription(option: ValueFormatOption.smart)
|
||||
logger.debug("SearchVisitor Visiting: [\(elementDesc)] at depth \(depth). Criteria: \(criteria.map { "\($0.attribute):\($0.value)"}.joined(separator: ", "))")
|
||||
|
||||
var matches = false
|
||||
if matchAllCriteriaBool {
|
||||
// Use the stored matchType
|
||||
if elementMatchesAllCriteria(element: element, criteria: criteria, matchType: self.matchType) {
|
||||
matches = true
|
||||
}
|
||||
} else {
|
||||
// Use the stored matchType
|
||||
if elementMatchesAnyCriterion(element: element, criteria: criteria, matchType: self.matchType) {
|
||||
matches = true
|
||||
}
|
||||
}
|
||||
|
||||
if matches {
|
||||
logger.debug("SearchVisitor MATCH: [\(elementDesc)] at depth \(depth).")
|
||||
foundElement = element
|
||||
allFoundElements.append(element)
|
||||
if stopAtFirstMatchInternal {
|
||||
logger.debug("SearchVisitor: stopAtFirstMatchInternal is true. Stopping search.")
|
||||
return .stop
|
||||
}
|
||||
} else {
|
||||
logger.debug("SearchVisitor NO MATCH: [\(elementDesc)] at depth \(depth).")
|
||||
}
|
||||
return .continue
|
||||
}
|
||||
|
||||
// Resets the visitor state for reuse, e.g., when searching different branches of a tree.
|
||||
public func reset() {
|
||||
self.foundElement = nil
|
||||
self.allFoundElements.removeAll()
|
||||
self.currentMaxDepthReachedByVisitor = 0 // Reset depth
|
||||
// logger.debug("SearchVisitor reset.") // Optional: for debugging visitor lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Collect All Visitor Implementation
|
||||
|
||||
@MainActor
|
||||
/* public -> internal */ internal func collectAll(
|
||||
appElement: Element,
|
||||
locator: Locator,
|
||||
currentElement: Element,
|
||||
depth: Int,
|
||||
maxDepth: Int,
|
||||
maxElements: Int?,
|
||||
visitor: CollectAllVisitor
|
||||
) async -> [AXElementData]? {
|
||||
var traverser = TreeTraverser()
|
||||
var state = TraversalState(maxDepth: maxDepth, startElement: currentElement)
|
||||
_ = await traverser.traverse(from: currentElement, visitor: visitor, state: &state)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "collectAll: Traversal complete. Collected \\(visitor.collectedElements.count) elements."))
|
||||
return visitor.collectedElements
|
||||
public class CollectAllVisitor: ElementVisitor {
|
||||
private(set) var collectedElements: [Element] = []
|
||||
let criteria: [Criterion]?
|
||||
let includeIgnored: Bool
|
||||
|
||||
init(criteria: [Criterion]? = nil, includeIgnored: Bool = false) {
|
||||
self.criteria = criteria
|
||||
self.includeIgnored = includeIgnored
|
||||
let criteriaDebug = criteria?.map { "\($0.attribute):\($0.value)(\($0.match_type?.rawValue ?? "exact"))" }.joined(separator: ", ") ?? "all"
|
||||
logger.debug("CollectAllVisitor Init: Criteria: [\(criteriaDebug)], IncludeIgnored: \(includeIgnored)")
|
||||
}
|
||||
|
||||
public func visit(element: Element, depth: Int) -> TreeVisitorResult {
|
||||
let elementDesc = element.briefDescription(option: ValueFormatOption.smart)
|
||||
logger.debug("CollectAllVisitor Visiting: [\(elementDesc)] at depth \(depth).")
|
||||
|
||||
if !includeIgnored && element.isIgnored() {
|
||||
logger.debug("CollectAllVisitor: Skipping ignored element [\(elementDesc)] because includeIgnored is false.")
|
||||
return .skipChildren // Skip ignored elements and their children if not including ignored
|
||||
}
|
||||
|
||||
if let criteria = criteria {
|
||||
if elementMatchesAllCriteria(element: element, criteria: criteria) {
|
||||
logger.debug("CollectAllVisitor: Adding [\(elementDesc)] (matched criteria).")
|
||||
collectedElements.append(element)
|
||||
} else {
|
||||
logger.debug("CollectAllVisitor: [\(elementDesc)] did NOT match criteria.")
|
||||
}
|
||||
} else {
|
||||
// No criteria, collect all (respecting includeIgnored)
|
||||
logger.debug("CollectAllVisitor: Adding [\(elementDesc)] (no criteria given).")
|
||||
collectedElements.append(element)
|
||||
}
|
||||
return .continue
|
||||
}
|
||||
}
|
||||
|
||||
// Remaining functions in this file (like path navigation helpers if any outside findElementViaPathAndCriteria)
|
||||
// would need similar review and refactoring if they use the old logging pattern.
|
||||
|
||||
// MARK: - Element Search Logic
|
||||
|
||||
// [REMOVED OLD findElement FUNCTION]
|
||||
|
||||
// MARK: - Path Navigator (Remains mostly the same, but uses TraversalContext for logging)
|
||||
|
||||
// [REMOVED OLD navigateToElementByPath FUNCTION]
|
||||
|
||||
// MARK: - Tree Traversal Utilities (SearchVisitor, TreeTraverser, TraversalState)
|
||||
// SearchVisitor is now defined in TreeTraversal.swift to avoid duplication
|
||||
// Note: Ensure `getApplicationElement` from PathNavigator is accessible and synchronous.
|
||||
// Ensure `navigateToElementByJSONPathHint` from PathNavigator is accessible and synchronous.
|
||||
// Ensure `elementMatchesAllCriteria` from SearchCriteriaUtils is accessible and synchronous.
|
||||
// Ensure `Criterion` struct and `Locator` struct are defined and accessible.
|
||||
// AXMiscConstants should be available. Example: public enum AXMiscConstants { public static let defaultMaxDepthSearch: Int = 10 }
|
||||
|
||||
@ -3,8 +3,12 @@
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
import AppKit // Added for NSRunningApplication
|
||||
import Logging // Import Logging
|
||||
|
||||
// Note: Assumes Element, PathUtils, Attribute are available.
|
||||
// Note: Assumes Element, PathUtils, Attribute, AXMiscConstants are available.
|
||||
|
||||
// Define logger for this file
|
||||
private let logger = Logger(label: "AXorcist.PathNavigator")
|
||||
|
||||
// New helper to check if an element matches all given criteria
|
||||
@MainActor
|
||||
@ -12,28 +16,33 @@ private func elementMatchesAllCriteria(
|
||||
_ element: Element,
|
||||
criteria: [String: String],
|
||||
forPathComponent pathComponentForLog: String // For logging
|
||||
) async -> Bool {
|
||||
let elementDescriptionForLog = element.briefDescription(option: .smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Checking element [\\(elementDescriptionForLog)] against criteria for component [\\(pathComponentForLog)]. Criteria count: \\(criteria.count). Criteria: \\(criteria)"))
|
||||
) -> Bool {
|
||||
let elementDescriptionForLog = element.briefDescription(option: ValueFormatOption.smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_START: Checking element [\(elementDescriptionForLog)] for component [\(pathComponentForLog)]. Criteria: \(criteria)"))
|
||||
|
||||
if criteria.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Criteria empty for component [\\(pathComponentForLog)]. Element [\\(elementDescriptionForLog)] considered a match by default."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Criteria empty for component [\(pathComponentForLog)]. Element [\(elementDescriptionForLog)] considered a match by default."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] MATCHED (empty criteria) for component [\(pathComponentForLog)]."))
|
||||
return true
|
||||
}
|
||||
|
||||
for (key, expectedValue) in criteria {
|
||||
// Determine matchType based on key or default to .exact
|
||||
// This is a simplified placeholder. Real logic might infer from key or have explicit matchTypes per criterion.
|
||||
let matchTypeForKey: JSONPathHintComponent.MatchType = (key.lowercased() == AXAttributeNames.kAXDOMClassListAttribute.lowercased()) ? .contains : .exact
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC_CRITERION: Checking criterion '\(key): \(expectedValue)' (matchType: \(matchTypeForKey.rawValue)) on element [\(elementDescriptionForLog)] for component [\(pathComponentForLog)]."))
|
||||
|
||||
// matchSingleCriterion is async
|
||||
if await !matchSingleCriterion(element: element, key: key, expectedValue: expectedValue, matchType: matchTypeForKey, elementDescriptionForLog: elementDescriptionForLog) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\\(elementDescriptionForLog)] FAILED to match criterion '\\(key): \\(expectedValue)' for component [\\(pathComponentForLog)]."))
|
||||
let criterionDidMatch = matchSingleCriterion(element: element, key: key, expectedValue: expectedValue, matchType: matchTypeForKey, elementDescriptionForLog: elementDescriptionForLog)
|
||||
let message = "PathNav/EMAC_CRITERION_RESULT: Criterion '\(key): \(expectedValue)' on [\(elementDescriptionForLog)] for [\(pathComponentForLog)]: \(criterionDidMatch ? "MATCHED" : "FAILED")"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: message))
|
||||
|
||||
if !criterionDidMatch {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\(elementDescriptionForLog)] FAILED to match criterion '\(key): \(expectedValue)' for component [\(pathComponentForLog)]."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] FAILED for component [\(pathComponentForLog)]."))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\\(elementDescriptionForLog)] successfully MATCHED ALL criteria for component [\\(pathComponentForLog)]."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/EMAC: Element [\(elementDescriptionForLog)] successfully MATCHED ALL criteria for component [\(pathComponentForLog)]."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/EMAC_END: Element [\(elementDescriptionForLog)] MATCHED ALL criteria for component [\(pathComponentForLog)]."))
|
||||
return true
|
||||
}
|
||||
|
||||
@ -42,8 +51,8 @@ private func elementMatchesAllCriteria(
|
||||
internal func navigateToElement(
|
||||
from startElement: Element,
|
||||
pathHint: [String],
|
||||
maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch
|
||||
) async -> Element? {
|
||||
maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch
|
||||
) -> Element? {
|
||||
var currentElement = startElement
|
||||
var currentPathSegmentForLog = ""
|
||||
|
||||
@ -56,17 +65,17 @@ internal func navigateToElement(
|
||||
}
|
||||
|
||||
if index >= maxDepth {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation aborted: Path hint index \\(index) reached maxDepth \\(maxDepth). Path so far: \\(currentPathSegmentForLog)"))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation aborted: Path hint index \(index) reached maxDepth \(maxDepth). Path so far: \(currentPathSegmentForLog)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
let criteriaToMatch = PathUtils.parseRichPathComponent(pathComponentString)
|
||||
guard !criteriaToMatch.isEmpty else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "CRITICAL_NAV_PARSE_FAILURE_MARKER: Empty or unparsable criteria from pathComponentString '\\(pathComponentString)'"))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "CRITICAL_NAV_PARSE_FAILURE_MARKER: Empty or unparsable criteria from pathComponentString '\(pathComponentString)'"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if let nextElement = await processPathComponent(
|
||||
if let nextElement = processPathComponent(
|
||||
currentElement: currentElement,
|
||||
pathComponentString: pathComponentString,
|
||||
criteriaToMatch: criteriaToMatch,
|
||||
@ -78,7 +87,7 @@ internal func navigateToElement(
|
||||
}
|
||||
}
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation successful. Final element: \\(currentElement.briefDescription(option: .smart))"))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigation successful. Final element: \(currentElement.briefDescription(option: ValueFormatOption.smart))"))
|
||||
return currentElement
|
||||
}
|
||||
|
||||
@ -88,41 +97,41 @@ private func processPathComponent(
|
||||
pathComponentString: String,
|
||||
criteriaToMatch: [String: String],
|
||||
currentPathSegmentForLog: String
|
||||
) async -> Element? {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC_DIRECT_LOG: Entered for \\(pathComponentString)"))
|
||||
) -> Element? {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC_DIRECT_LOG: Entered for \(pathComponentString)"))
|
||||
|
||||
var stepCounter = 0
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). Before briefDesc."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). Before briefDesc."))
|
||||
stepCounter += 1
|
||||
let briefDesc = currentElement.briefDescription(option: .smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). Before logPathComponentProcessing. BriefDesc: \\(briefDesc)"))
|
||||
let briefDesc = currentElement.briefDescription(option: ValueFormatOption.smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). Before logPathComponentProcessing. BriefDesc: \(briefDesc)"))
|
||||
stepCounter += 1
|
||||
logPathComponentProcessing(pathComponentString: pathComponentString, briefDesc: briefDesc)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). After logPathComponentProcessing. Before PRE-CALL FMIC."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). After logPathComponentProcessing. Before PRE-CALL FMIC."))
|
||||
stepCounter += 1
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: PRE-CALL FMIC"))
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). After PRE-CALL FMIC. Before findMatchingChild call."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). After PRE-CALL FMIC. Before findMatchingChild call."))
|
||||
stepCounter += 1
|
||||
|
||||
if let matchedChild = await findMatchingChild(
|
||||
if let matchedChild = findMatchingChild(
|
||||
currentElement: currentElement,
|
||||
criteriaToMatch: criteriaToMatch,
|
||||
pathComponentForLog: pathComponentString
|
||||
) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). findMatchingChild returned non-nil."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). findMatchingChild returned non-nil."))
|
||||
return matchedChild
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). findMatchingChild returned nil. Before elementMatchesAllCriteria."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). findMatchingChild returned nil. Before elementMatchesAllCriteria."))
|
||||
stepCounter += 1
|
||||
|
||||
if await elementMatchesAllCriteria(currentElement, criteria: criteriaToMatch, forPathComponent: pathComponentString) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Current element \\(briefDesc) itself matches component '\\(pathComponentString)'. Retaining current element for this step."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). elementMatchesAllCriteria on currentElement was true."))
|
||||
if elementMatchesAllCriteria(currentElement, criteria: criteriaToMatch, forPathComponent: pathComponentString) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Current element \(briefDesc) itself matches component '\(pathComponentString)'. Retaining current element for this step."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). elementMatchesAllCriteria on currentElement was true."))
|
||||
return currentElement
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). elementMatchesAllCriteria on currentElement was false. Before logNoMatchFound."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). elementMatchesAllCriteria on currentElement was false. Before logNoMatchFound."))
|
||||
stepCounter += 1
|
||||
|
||||
logNoMatchFound(
|
||||
@ -130,13 +139,13 @@ private func processPathComponent(
|
||||
pathComponentString: pathComponentString,
|
||||
currentPathSegmentForLog: currentPathSegmentForLog
|
||||
)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \\(stepCounter). After logNoMatchFound. Returning nil."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC: Step \(stepCounter). After logNoMatchFound. Returning nil."))
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func logPathComponentProcessing(pathComponentString: String, briefDesc: String) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigating: Processing path component '\\(pathComponentString)' from current element: \\(briefDesc)"))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Navigating: Processing path component '\(pathComponentString)' from current element: \(briefDesc)"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -145,7 +154,7 @@ private func logNoMatchFound(
|
||||
pathComponentString: String,
|
||||
currentPathSegmentForLog: String
|
||||
) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Neither current element \\(briefDesc) nor its children (after all checks) matched criteria for path component '\\(pathComponentString)'. Path: \\(currentPathSegmentForLog) // CHILD_MATCH_FAILURE_MARKER"))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Neither current element \(briefDesc) nor its children (after all checks) matched criteria for path component '\(pathComponentString)'. Path: \(currentPathSegmentForLog) // CHILD_MATCH_FAILURE_MARKER"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -153,37 +162,52 @@ private func findMatchingChild(
|
||||
currentElement: Element,
|
||||
criteriaToMatch: [String: String],
|
||||
pathComponentForLog: String
|
||||
) async -> Element? {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Entered. CurrentElement: \\(currentElement.briefDescription(option: .smart)). Component: \\(pathComponentForLog)"))
|
||||
) -> Element? {
|
||||
let parentElementDesc = currentElement.briefDescription(option: ValueFormatOption.smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_START: Searching children of [\(parentElementDesc)] for component [\(pathComponentForLog)]. Criteria: \(criteriaToMatch)"))
|
||||
|
||||
guard let children = currentElement.children() else {
|
||||
let currentElementDescForLog = currentElement.briefDescription(option: .smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\\(currentElementDescForLog)] has no children (returned nil for .children())."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Element [\(parentElementDesc)] has no children (returned nil for .children())."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: No children for [\(parentElementDesc)]. Returning nil."))
|
||||
return nil
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Element \\(currentElement.briefDescription(option: .smart)) has \\(children.count) children. Iterating..."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Element \(parentElementDesc) has \(children.count) children. Iterating..."))
|
||||
|
||||
for child in children {
|
||||
let childDesc = child.briefDescription(option: .smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Checking child [\\(childDesc)] against criteria for component [\\(pathComponentForLog)]."))
|
||||
if await elementMatchesAllCriteria(child, criteria: criteriaToMatch, forPathComponent: pathComponentForLog) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Child [\\(childDesc)] MATCHED for path component [\\(pathComponentForLog)]."))
|
||||
if children.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Element \(parentElementDesc) has an empty children array."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: Empty children array for [\(parentElementDesc)]. Returning nil."))
|
||||
return nil
|
||||
}
|
||||
|
||||
for (childIndex, child) in children.enumerated() {
|
||||
let childDesc = child.briefDescription(option: ValueFormatOption.smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC_CHILD: [Child \(childIndex + 1)/\(children.count)] Processing child [\(childDesc)] of [\(parentElementDesc)] for component [\(pathComponentForLog)]."))
|
||||
|
||||
let childMatched = elementMatchesAllCriteria(child, criteria: criteriaToMatch, forPathComponent: pathComponentForLog)
|
||||
let message = "PathNav/FMC_CHILD_RESULT: Child [\(childDesc)] of [\(parentElementDesc)] for [\(pathComponentForLog)]: \(childMatched ? "MATCHED" : "DID NOT MATCH")"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: message))
|
||||
|
||||
if childMatched {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Child [\(childDesc)] MATCHED for path component [\(pathComponentForLog)]."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: Found matching child [\(childDesc)] for [\(parentElementDesc)]. Returning child."))
|
||||
return child
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Child [\\(childDesc)] did NOT match criteria for [\\(pathComponentForLog)]. Continuing."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: Child [\(childDesc)] did NOT match criteria for [\(pathComponentForLog)]. Continuing."))
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: No child of \\(currentElement.briefDescription(option: .smart)) matched criteria for [\\(pathComponentForLog)]. Returning nil."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FMC: No child of \(parentElementDesc) matched criteria for [\(pathComponentForLog)]."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/FMC_END: No matching child found for [\(parentElementDesc)]. Returning nil."))
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func getChildrenFromElement(_ element: Element) async -> [Element]? {
|
||||
private func getChildrenFromElement(_ element: Element) -> [Element]? {
|
||||
guard let children = element.children() else {
|
||||
let currentElementDescForLog = element.briefDescription(option: .smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\\(currentElementDescForLog)] has no children (returned nil for .children())."))
|
||||
let currentElementDescForLog = element.briefDescription(option: ValueFormatOption.smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\(currentElementDescForLog)] has no children (returned nil for .children())."))
|
||||
return nil
|
||||
}
|
||||
if children.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\\(element.briefDescription(option: .smart))] has zero children (returned empty array for .children())."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element [\(element.briefDescription(option: ValueFormatOption.smart))] has zero children (returned empty array for .children())."))
|
||||
}
|
||||
return children
|
||||
}
|
||||
@ -198,13 +222,13 @@ private func getChildrenFromElement(_ element: Element) async -> [Element]? {
|
||||
// (e.g., by returning a special key like "@index" in criteriaToMatch)
|
||||
|
||||
// In processPathComponent, after trying findMatchingChild and elementMatchesAllCriteria(currentElement...):
|
||||
if let indexStr = criteriaToMatch["@index"], let index = Int(indexStr) {
|
||||
if let children = getChildrenFromElement(currentElement), index >= 0, index < children.count {
|
||||
if let indexStr = criteriaToMatch[\"@index\"], let index = Int(indexStr) {
|
||||
if let children = await getChildrenFromElement(currentElement), index >= 0, index < children.count { // Added await
|
||||
let indexedChild = children[index]
|
||||
axDebugLog("Path component '\(pathComponentString)' resolved to child at index \(index): \(indexedChild.briefDescription())")
|
||||
await axDebugLog(\"Path component \'\\(pathComponentString)\' resolved to child at index \\(index): \\(await indexedChild.briefDescription())\") // Added await
|
||||
return indexedChild
|
||||
} else {
|
||||
axDebugLog("Path component '\(pathComponentString)' (index \(index)) out of bounds for \(currentElement.briefDescription()) with \(getChildrenFromElement(currentElement)?.count ?? 0) children.")
|
||||
await axDebugLog(\"Path component \'\\(pathComponentString)\' (index \\(index)) out of bounds for \\(await currentElement.briefDescription()) with \\(await getChildrenFromElement(currentElement)?.count ?? 0) children.\") // Added await
|
||||
// logNoMatchFound would have been called if attribute matching failed before this.
|
||||
// If ONLY index was provided and it failed, this is the failure point.
|
||||
return nil
|
||||
@ -224,277 +248,263 @@ internal func original_currentElementMatchesPathComponent( // Marked as original
|
||||
_ element: Element,
|
||||
attributeName: String,
|
||||
expectedValue: String
|
||||
) -> Bool {
|
||||
) async -> Bool { // Made async
|
||||
if attributeName.isEmpty {
|
||||
axWarningLog("original_currentElementMatchesPathComponent: attributeName is empty.")
|
||||
await axWarningLog(\"original_currentElementMatchesPathComponent: attributeName is empty.\") // Added await
|
||||
return false
|
||||
}
|
||||
if let actualValue: String = element.attribute(Attribute(attributeName)) {
|
||||
if actualValue == expectedValue {
|
||||
return true
|
||||
// ... (rest of original function would need similar async/await updates for attribute access and logging) ...
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
// MARK: - JSON PathHint Navigation
|
||||
|
||||
// Helper to convert JSONPathHintComponent.AttributeName to actual AXAttribute string
|
||||
// This might be better placed in a utility struct/enum for AttributeName if it becomes complex
|
||||
// For now, a simple switch based on the rawValue of the enum.
|
||||
// UPDATE: This function is problematic because JSONPathHintComponent.AttributeName does not exist.
|
||||
// The `attribute` in JSONPathHintComponent is already a String.
|
||||
// This function might have been intended for an earlier version of JSONPathHintComponent.
|
||||
// Keeping it commented out for now. If direct attribute string usage in JSONPathHintComponent is correct, this is not needed.
|
||||
/*
|
||||
private func jsonPathHintAttrToAXAttribute(_ attrName: JSONPathHintComponent.AttributeName) -> String {
|
||||
switch attrName {
|
||||
case .role: return AXAttributeNames.kAXRoleAttribute
|
||||
case .subrole: return AXAttributeNames.kAXSubroleAttribute
|
||||
case .identifier: return AXAttributeNames.kAXIdentifierAttribute
|
||||
case .title: return AXAttributeNames.kAXTitleAttribute
|
||||
case .value: return AXAttributeNames.kAXValueAttribute
|
||||
case .description: return AXAttributeNames.kAXDescriptionAttribute
|
||||
// Add other cases as necessary from JSONPathHintComponent.AttributeName
|
||||
default:
|
||||
// Fallback or error for unhandled cases
|
||||
// For now, using the rawValue, but this implies AttributeName has a rawValue or is a string itself.
|
||||
// This needs to be aligned with the actual definition of JSONPathHintComponent.AttributeName.
|
||||
// If attrName is already the string (e.g. "AXRole"), then this function is not needed.
|
||||
// The error "String has no member rawValue" likely points to this.
|
||||
// If JSONPathHintComponent.attribute is already a String, this function becomes:
|
||||
// private func jsonPathHintAttrToAXAttribute(_ attrName: String) -> String { return attrName }
|
||||
// ... or it's just used directly.
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "jsonPathHintAttrToAXAttribute: Unhandled or direct-use attribute name '\(attrName)'. Using rawValue if available, otherwise direct string."))
|
||||
// Assuming attrName might conform to RawRepresentable<String> if it's an enum
|
||||
// Or if it's already a string, this part is overly complex.
|
||||
if let raw = (attrName as? any RawRepresentable)?.rawValue as? String {
|
||||
return raw
|
||||
}
|
||||
return String(describing: attrName) // Fallback, likely incorrect if attrName isn't directly the string.
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
// Updated navigateToElementByJSONPathHint to use the new Element API and logging
|
||||
@MainActor
|
||||
internal func navigateToElementByJSONPathHint(
|
||||
from startElement: Element,
|
||||
jsonPathHint: [JSONPathHintComponent],
|
||||
overallMaxDepth: Int = AXMiscConstants.defaultMaxDepthSearch,
|
||||
initialPathSegmentForLog: String = "Root"
|
||||
) -> Element? {
|
||||
var currentElement = startElement
|
||||
var currentPathSegmentForLog = initialPathSegmentForLog
|
||||
let pathHintCount = jsonPathHint.count
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_START: From [\(startElement.briefDescription(option: ValueFormatOption.smart))] with hint (count: \(pathHintCount)): \(jsonPathHint.map { $0.descriptionForLog() }.joined(separator: " -> "))"))
|
||||
|
||||
for (index, pathComponent) in jsonPathHint.enumerated() {
|
||||
let componentDescForLog = pathComponent.descriptionForLog()
|
||||
currentPathSegmentForLog += (index > 0 ? " -> " : " (Start) -> ") + componentDescForLog
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_COMPONENT [\(index + 1)/\(pathHintCount)]: Processing '\(componentDescForLog)'. Current path: [\(currentPathSegmentForLog)]"))
|
||||
|
||||
let depthForThisStep = pathComponent.depth ?? AXMiscConstants.defaultMaxDepthSearchForHintStep
|
||||
|
||||
if index >= overallMaxDepth {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV: Path hint index \(index) reached overallMaxDepth \(overallMaxDepth). Path so far: \(currentPathSegmentForLog)"))
|
||||
return nil
|
||||
}
|
||||
|
||||
let attributeToMatch = pathComponent.attribute
|
||||
let valueToMatch = pathComponent.value
|
||||
let matchType = pathComponent.matchType ?? .exact
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_COMPONENT_DETAILS: Attribute: '\(attributeToMatch)', Value: '\(valueToMatch)', MatchType: '\(matchType.rawValue)', DepthForStep: \(depthForThisStep)"))
|
||||
|
||||
let searchCriteria = [Criterion(attribute: attributeToMatch, value: valueToMatch, matchType: matchType)]
|
||||
|
||||
let foundElement = findDescendantMatchingCriteria(
|
||||
startElement: currentElement,
|
||||
criteria: searchCriteria,
|
||||
maxDepth: depthForThisStep,
|
||||
stopAtFirstMatch: true,
|
||||
pathComponentForLog: componentDescForLog
|
||||
)
|
||||
|
||||
if let nextElement = foundElement {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_MATCH: Component '\(componentDescForLog)' matched by [\(nextElement.briefDescription(option: ValueFormatOption.smart))]. Updating current element."))
|
||||
currentElement = nextElement
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON_NAV_NO_MATCH: Component '\(componentDescForLog)' did not match any element from [\(currentElement.briefDescription(option: ValueFormatOption.smart))] within depth \(depthForThisStep). Path: \(currentPathSegmentForLog)"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/JSON_NAV_SUCCESS: Navigation successful. Final element: [\(currentElement.briefDescription(option: ValueFormatOption.smart))] after path: [\(currentPathSegmentForLog)]"))
|
||||
return currentElement
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func original_checkChildMatch( // Marked as original
|
||||
child: Element,
|
||||
attributeName: String,
|
||||
expectedValue: String
|
||||
private func findDescendantMatchingCriteria(
|
||||
startElement: Element,
|
||||
criteria: [Criterion],
|
||||
maxDepth: Int,
|
||||
stopAtFirstMatch: Bool,
|
||||
pathComponentForLog: String
|
||||
) -> Element? {
|
||||
let childBriefDescForLog = child.briefDescription(option: .smart)
|
||||
|
||||
guard let actualValue: String = child.attribute(Attribute(attributeName)) else {
|
||||
if elementMatchesAllCriteria(element: startElement, criteria: criteria) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FDMC: Start element [\(startElement.briefDescription(option: ValueFormatOption.smart))] itself matches criteria for path component '\(pathComponentForLog)'."))
|
||||
return startElement
|
||||
}
|
||||
|
||||
if maxDepth <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
original_logChildCheck( // Use original log
|
||||
childDesc: childBriefDescForLog,
|
||||
attributeName: attributeName,
|
||||
actualValue: actualValue,
|
||||
expectedValue: expectedValue
|
||||
)
|
||||
guard let children = startElement.children() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if actualValue == expectedValue {
|
||||
original_logChildMatch( // Use original log
|
||||
childDesc: childBriefDescForLog,
|
||||
attributeName: attributeName,
|
||||
expectedValue: expectedValue
|
||||
)
|
||||
return child
|
||||
for child in children {
|
||||
if let found = findDescendantMatchingCriteria(
|
||||
startElement: child,
|
||||
criteria: criteria,
|
||||
maxDepth: maxDepth - 1,
|
||||
stopAtFirstMatch: stopAtFirstMatch,
|
||||
pathComponentForLog: pathComponentForLog
|
||||
) {
|
||||
if stopAtFirstMatch {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func original_logChildCheck( // Marked as original
|
||||
childDesc: String,
|
||||
attributeName: String,
|
||||
actualValue: String,
|
||||
expectedValue: String
|
||||
) {
|
||||
let matchStatus = (actualValue == expectedValue) ? "==" : "!="
|
||||
axDebugLog(
|
||||
"Checking child: \(childDesc) | Attribute: \(attributeName) | Actual: '\(actualValue)' \(matchStatus) Expected: '\(expectedValue)'",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
}
|
||||
// MARK: - Application Root Element Navigation
|
||||
|
||||
@MainActor
|
||||
private func original_logChildMatch( // Marked as original
|
||||
childDesc: String,
|
||||
attributeName: String,
|
||||
expectedValue: String
|
||||
) {
|
||||
axDebugLog(
|
||||
"MATCHED child: \(childDesc) for \(attributeName):\(expectedValue)",
|
||||
file: #file,
|
||||
function: #function,
|
||||
line: #line
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
// MARK: - JSON Path Hint Navigation
|
||||
|
||||
// Main external entry point for JSON path hint navigation
|
||||
@MainActor
|
||||
func navigateToElementByJSONPathHint(
|
||||
from startElement: Element,
|
||||
pathHintComponents: [JSONPathHintComponent]
|
||||
) async -> Element? {
|
||||
var currentElement = startElement
|
||||
let pathDescriptionForLog = pathHintComponents.map { "\($0.attribute):\($0.value)" }.joined(separator: " -> ")
|
||||
let initialMessage = "PathNav/JSON: Starting navigation with \\(pathHintComponents.count) JSON components from \\(currentElement.briefDescription(option: .smart)). Path: \\(pathDescriptionForLog)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: initialMessage))
|
||||
|
||||
for (index, component) in pathHintComponents.enumerated() {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON: Processing component \\(index + 1)/\\(pathHintComponents.count): [\\(component.attribute):\\(component.value)] from current element [\\(currentElement.briefDescription(option: .smart))]"))
|
||||
|
||||
if let nextElement = await findDescendantMatchingCriteria(
|
||||
startingFrom: currentElement,
|
||||
hintComponent: component,
|
||||
pathComponentForLog: "JSONHintStep_\\(index)_\(component.attribute)"
|
||||
) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/JSON: Component \\(index + 1) matched. New current element: [\\(nextElement.briefDescription(option: .smart))]"))
|
||||
currentElement = nextElement
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/JSON: Component \\(index + 1) [\\(component.attribute):\\(component.value)] did NOT match any descendant from [\\(currentElement.briefDescription(option: .smart))]. Navigation failed."))
|
||||
return nil
|
||||
}
|
||||
public func getApplicationElement(for bundleIdentifier: String) -> Element? {
|
||||
guard let runningApp = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/AppEl: No running application found for bundle ID '\(bundleIdentifier)'."))
|
||||
return nil
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/JSON: Navigation successful. Final element: [\\(currentElement.briefDescription(option: .smart))]"))
|
||||
return currentElement
|
||||
let pid = runningApp.processIdentifier
|
||||
let appElement = Element(AXUIElementCreateApplication(pid))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/AppEl: Obtained application element for '\(bundleIdentifier)' (PID: \(pid)): [\(appElement.briefDescription(option: ValueFormatOption.smart))]"))
|
||||
return appElement
|
||||
}
|
||||
|
||||
// Searches descendants of `startingFrom` (inclusive of self if depth allows) for an element matching criteria in `hintComponent`.
|
||||
@MainActor
|
||||
private func findDescendantMatchingCriteria(
|
||||
startingFrom element: Element,
|
||||
hintComponent: JSONPathHintComponent,
|
||||
pathComponentForLog: String // For logging, e.g., "JSONHintStep_0_ROLE"
|
||||
) async -> Element? {
|
||||
let currentElementDesc = element.briefDescription(option: .smart)
|
||||
let matchTypeRawValue = hintComponent.matchType?.rawValue ?? "exact_fallback"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/FindDesc: Searching for [\\(hintComponent.attribute):\\(hintComponent.value)] starting from [\\(currentElementDesc)] with depth \\(hintComponent.depth ?? -1)"))
|
||||
|
||||
// Convert JSONPathHintComponent to a [String: String] criteria map
|
||||
// This uses the mapped AXAttributeName (e.g., kAXRoleAttribute)
|
||||
var criteria: [String: String] = [:]
|
||||
if let axAttr = hintComponent.axAttributeName {
|
||||
criteria[axAttr] = hintComponent.value
|
||||
public func getApplicationElement(for processId: pid_t) -> Element? {
|
||||
let appElement = Element(AXUIElementCreateApplication(processId))
|
||||
let bundleIdMessagePart: String
|
||||
if let runningApp = NSRunningApplication(processIdentifier: processId), let bId = runningApp.bundleIdentifier {
|
||||
bundleIdMessagePart = " (\(bId))"
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "PathNav/FindDesc: Unknown attribute type '\\(hintComponent.attribute)' in JSON hint. Cannot build criteria."))
|
||||
return nil
|
||||
bundleIdMessagePart = ""
|
||||
}
|
||||
|
||||
let maxDepthToSearch = hintComponent.depth ?? JSONPathHintComponent.defaultDepthForSegment // Use component depth or default step depth
|
||||
|
||||
// Use a breadth-first or depth-first search up to maxDepthToSearch
|
||||
// For simplicity, using a recursive depth-limited search helper.
|
||||
// This helper will check the current element first, then its children, respecting depth.
|
||||
return await searchRecursiveForCriteria(
|
||||
currentElement: element,
|
||||
criteria: criteria,
|
||||
matchType: hintComponent.matchType,
|
||||
currentDepth: 0,
|
||||
maxDepth: maxDepthToSearch,
|
||||
pathComponentForLog: pathComponentForLog
|
||||
)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "PathNav/AppEl: Obtained application element for PID \(processId)\(bundleIdMessagePart): [\(appElement.briefDescription(option: ValueFormatOption.smart))]"))
|
||||
return appElement
|
||||
}
|
||||
|
||||
// Recursive helper for findDescendantMatchingCriteria
|
||||
// MARK: - Element from Path (High-Level)
|
||||
|
||||
@MainActor
|
||||
private func searchRecursiveForCriteria(
|
||||
currentElement: Element,
|
||||
criteria: [String: String],
|
||||
matchType: JSONPathHintComponent.MatchType?,
|
||||
currentDepth: Int,
|
||||
public func getElement(
|
||||
appIdentifier: String,
|
||||
pathHint: [Any],
|
||||
maxDepth: Int = AXMiscConstants.defaultMaxDepthSearch
|
||||
) -> Element? {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Attempting to get element for app '\(appIdentifier)' with path hint (count: \(pathHint.count))."))
|
||||
|
||||
let startElement: Element?
|
||||
if let pid = pid_t(appIdentifier) {
|
||||
startElement = getApplicationElement(for: pid)
|
||||
} else {
|
||||
startElement = getApplicationElement(for: appIdentifier)
|
||||
}
|
||||
|
||||
guard let rootElement = startElement else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/GetEl: Could not get root application element for '\(appIdentifier)'."))
|
||||
return nil
|
||||
}
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Root element for '\(appIdentifier)' is [\(rootElement.briefDescription(option: ValueFormatOption.smart))]. Processing path hint."))
|
||||
|
||||
if let stringPathHint = pathHint as? [String] {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Interpreting path hint as [String]. Count: \(stringPathHint.count). Hint: \(stringPathHint.joined(separator: " -> "))"))
|
||||
return navigateToElement(from: rootElement, pathHint: stringPathHint, maxDepth: maxDepth)
|
||||
} else if let jsonPathHint = pathHint as? [JSONPathHintComponent] {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/GetEl: Interpreting path hint as [JSONPathHintComponent]. Count: \(jsonPathHint.count). Hint: \(jsonPathHint.map { $0.descriptionForLog() }.joined(separator: " -> "))"))
|
||||
let initialLogSegment = rootElement.role() == AXRoleNames.kAXApplicationRole ? "Application" : rootElement.briefDescription(option: ValueFormatOption.smart)
|
||||
return navigateToElementByJSONPathHint(from: rootElement, jsonPathHint: jsonPathHint, overallMaxDepth: maxDepth, initialPathSegmentForLog: initialLogSegment)
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "PathNav/GetEl: Path hint type is not [String] or [JSONPathHintComponent]. Hint: \(pathHint). Cannot navigate."))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func findDescendantAtPath(
|
||||
currentRoot: Element,
|
||||
pathComponents: [PathStep],
|
||||
maxDepth: Int,
|
||||
pathComponentForLog: String
|
||||
) async -> Element? {
|
||||
let currentElementDesc = currentElement.briefDescription(option: .smart)
|
||||
let matchTypeRawValue = matchType?.rawValue ?? "exact_fallback"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/SearchRec: Visiting [\\(currentElementDesc)] at depth \\(currentDepth) (max: \\(maxDepth)) for criteria [\\(criteria)] (match: \\(matchTypeRawValue))"))
|
||||
debugSearch: Bool
|
||||
) -> Element? {
|
||||
var currentElement = currentRoot
|
||||
logger.debug("PathNav/findDescendantAtPath: Starting path navigation. Initial root: \\(currentElement.briefDescription(option: .smart)). Path components: \\(pathComponents.count)")
|
||||
|
||||
// Check if current element matches
|
||||
// elementMatchesAllCriteria is now synchronous
|
||||
if await elementMatchesAllCriteria(currentElement, criteria: criteria, forPathComponent: pathComponentForLog) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/SearchRec: [\\(currentElementDesc)] MATCHED criteria at depth \\(currentDepth). PathComponent: \\(pathComponentForLog)"))
|
||||
return currentElement
|
||||
}
|
||||
for (_, component) in pathComponents.enumerated() {
|
||||
// Log messages will use pathComponents.count if needed, index isn't critical for current logging
|
||||
logger.debug("PathNav/findDescendantAtPath: Processing component. Current: \\(currentElement.briefDescription(option: .smart))")
|
||||
|
||||
let searchVisitor = SearchVisitor(
|
||||
criteria: component.criteria,
|
||||
matchType: component.matchType ?? .exact,
|
||||
matchAllCriteria: component.matchAllCriteria ?? true,
|
||||
stopAtFirstMatch: true,
|
||||
maxDepth: component.maxDepthForStep ?? 1
|
||||
)
|
||||
|
||||
// If maxDepth reached or no children, stop descent
|
||||
if currentDepth >= maxDepth {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/SearchRec: Max depth \\(maxDepth) reached for [\\(currentElementDesc)]. PathComponent: \\(pathComponentForLog). No deeper search."))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Element.children() is now synchronous
|
||||
guard let children = currentElement.children(), !children.isEmpty else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/SearchRec: [\\(currentElementDesc)] has no children or children array is empty. PathComponent: \\(pathComponentForLog). No deeper search."))
|
||||
return nil
|
||||
}
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/SearchRec: [\\(currentElementDesc)] has \\(children.count) children. Iterating..."))
|
||||
|
||||
for child in children {
|
||||
// searchRecursiveForCriteria is now synchronous
|
||||
if let matchedElement = await searchRecursiveForCriteria(
|
||||
currentElement: child,
|
||||
criteria: criteria,
|
||||
matchType: matchType,
|
||||
currentDepth: currentDepth + 1,
|
||||
maxDepth: maxDepth,
|
||||
pathComponentForLog: pathComponentForLog
|
||||
) {
|
||||
return matchedElement // Found in a descendant
|
||||
}
|
||||
}
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/SearchRec: No match found in [\\(currentElementDesc)] or its descendants up to depth \\(maxDepth) for criteria. PathComponent: \\(pathComponentForLog)."))
|
||||
return nil // Not found in this branch
|
||||
}
|
||||
|
||||
// Determines the starting element for a search based on path hints.
|
||||
@MainActor
|
||||
func processJSONPathHintAndDetermineStartElement(
|
||||
for appBundleID: String?,
|
||||
windowTitleHint: String?,
|
||||
pathHint: [JSONPathHintComponent]?
|
||||
) async -> Element? {
|
||||
let logMessage = "PathNav/ProcJSONHint: app=\(appBundleID ?? "nil"), window=\(windowTitleHint ?? "nil"), hintCount=\(pathHint?.count ?? 0)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: logMessage))
|
||||
|
||||
var startSearchElement: Element? = nil
|
||||
|
||||
if let bundleID = appBundleID, !bundleID.isEmpty {
|
||||
// Element.application(bundleIdentifier:) is now synchronous if we recreate it or use a sync alternative
|
||||
// For now, assuming a synchronous way to get the app element:
|
||||
guard let runningApp = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/ProcJSONHint: No running application found for bundle ID '\\(bundleID)'."))
|
||||
// Children of the current element are where we search for the next path component
|
||||
logger.debug("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] Current element for child search: \\(currentElement.briefDescription(option: .smart))")
|
||||
|
||||
guard let childrenToSearch = currentElement.children(strict: false), !childrenToSearch.isEmpty else {
|
||||
logger.warning("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] No children found (or list was empty) for \\(currentElement.briefDescription(option: .smart)). Path navigation cannot proceed further down this branch.")
|
||||
return nil
|
||||
}
|
||||
let appElement = Element(AXUIElementCreateApplication(runningApp.processIdentifier))
|
||||
// Basic check if appElement is valid (e.g., by trying to get its role)
|
||||
if appElement.role() == nil { // role() is sync
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/ProcJSONHint: Failed to create a valid application Element for PID \\(runningApp.processIdentifier) from bundleID '\\(bundleID)'. Role check failed."))
|
||||
return nil
|
||||
}
|
||||
startSearchElement = appElement
|
||||
let appDesc = startSearchElement?.briefDescription(option: .smart) ?? "nil"
|
||||
let appDescMessage = "PathNav/ProcJSONHint: Set start element to application [\\(bundleID)] - [\\(appDesc)]"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: appDescMessage))
|
||||
logger.debug("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] Found \\(childrenToSearch.count) children to search.")
|
||||
|
||||
if let titleHint = windowTitleHint, !titleHint.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/ProcJSONHint: Window title hint '\\(titleHint)' provided. Searching for window in [\\(bundleID)]."))
|
||||
// Search for the window within the application element
|
||||
// Element.windows() and Element.title() are now synchronous
|
||||
if let windows = startSearchElement?.windows() {
|
||||
var foundWindow: Element? = nil
|
||||
for window in windows {
|
||||
if let windowTitle = window.title(), windowTitle.contains(titleHint) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/ProcJSONHint: Found matching window by title '\\(windowTitle)' (hint: '\\(titleHint)'))"))
|
||||
foundWindow = window
|
||||
break
|
||||
}
|
||||
}
|
||||
if let window = foundWindow {
|
||||
startSearchElement = window
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/ProcJSONHint: Updated start element to specific window [\\(window.briefDescription(option: .smart))]"))
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/ProcJSONHint: Window with title containing '\\(titleHint)' not found in application [\\(bundleID)]. "))
|
||||
return nil // Window hint provided but not found
|
||||
}
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "PathNav/ProcJSONHint: Application [\\(bundleID)] has no windows or failed to retrieve them."))
|
||||
return nil // App has no windows
|
||||
var foundMatchForThisComponent: Element? = nil
|
||||
for child in childrenToSearch {
|
||||
searchVisitor.reset()
|
||||
traverseAndSearch(element: child, visitor: searchVisitor, currentDepth: 0, maxDepth: component.maxDepthForStep ?? 1)
|
||||
if let foundUnwrapped = searchVisitor.foundElement {
|
||||
logger.info("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] MATCHED component criteria \\(component.descriptionForLog()) on child: \\(foundUnwrapped.briefDescription(option: ValueFormatOption.smart))")
|
||||
foundMatchForThisComponent = foundUnwrapped
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No application specified, use system-wide element
|
||||
startSearchElement = Element.systemWide() // systemWide() is sync
|
||||
let systemWideDesc = startSearchElement?.briefDescription(option: .smart) ?? "nil"
|
||||
let systemWideMessage = "PathNav/ProcJSONHint: No app bundle ID. Defaulting start element to system-wide [\\(systemWideDesc)]"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: systemWideMessage))
|
||||
}
|
||||
|
||||
guard let nonNilStartElement = startSearchElement else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .error, message: "PathNav/ProcJSONHint: Could not determine a valid start search element."))
|
||||
return nil
|
||||
}
|
||||
|
||||
// If there's a path hint, navigate from the determined start element
|
||||
if let hintComponents = pathHint, !hintComponents.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/ProcJSONHint: Path hint provided (\\(hintComponents.count) components). Navigating from [\\(nonNilStartElement.briefDescription(option: .smart))]"))
|
||||
// navigateToElementByJSONPathHint is now synchronous
|
||||
return await navigateToElementByJSONPathHint(from: nonNilStartElement, pathHintComponents: hintComponents)
|
||||
} else {
|
||||
// No path hint, so the start element (app or window or systemWide) is the target
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/ProcJSONHint: No path hint. Returning determined start element: [\\(nonNilStartElement.briefDescription(option: .smart))]"))
|
||||
return nonNilStartElement
|
||||
if let nextElement = foundMatchForThisComponent {
|
||||
currentElement = nextElement
|
||||
logger.debug("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] Advancing to next element: \\(currentElement.briefDescription(option: .smart))")
|
||||
} else {
|
||||
logger.warning("PathNav/findDescendantAtPath: [Component \\(pathComponentIndex + 1)] FAILED to find match for component criteria: \\(component.descriptionForLog()) within children of \\(currentElement.briefDescription(option: .smart))")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
logger.info("PathNav/findDescendantAtPath: Successfully navigated full path. Final element: \\(currentElement.briefDescription(option: .smart))")
|
||||
return currentElement
|
||||
}
|
||||
|
||||
@ -43,11 +43,11 @@ public struct PathHintComponent {
|
||||
}
|
||||
|
||||
if mappedCriteria.isEmpty {
|
||||
axWarningLog("PathHintComponent: Path segment '\\(pathSegment)' produced no usable criteria after parsing.")
|
||||
axWarningLog("PathHintComponent: Path segment '\(pathSegment)' produced no usable criteria after parsing.")
|
||||
return nil
|
||||
}
|
||||
self.criteria = mappedCriteria
|
||||
axDebugLog("PathHintComponent initialized. Segment: '\\(pathSegment)' => criteria: \\(mappedCriteria)")
|
||||
axDebugLog("PathHintComponent initialized. Segment: '\(pathSegment)' => criteria: \(mappedCriteria)")
|
||||
}
|
||||
|
||||
init(criteria: [String: String], originalSegment: String = "") {
|
||||
@ -56,45 +56,61 @@ public struct PathHintComponent {
|
||||
}
|
||||
|
||||
// PathHintComponent uses exact matching by default when calling elementMatchesCriteria
|
||||
func matches(element: Element) async -> Bool {
|
||||
return await elementMatchesCriteria(element, criteria: self.criteria, matchType: .exact)
|
||||
func matches(element: Element) -> Bool {
|
||||
return elementMatchesCriteria(element, criteria: self.criteria, matchType: JSONPathHintComponent.MatchType.exact)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Criteria Matching Helper
|
||||
|
||||
@MainActor
|
||||
public func elementMatchesCriteria(
|
||||
_ element: Element,
|
||||
public func elementMatchesAllCriteria(
|
||||
element: Element,
|
||||
criteria: [Criterion],
|
||||
matchType: JSONPathHintComponent.MatchType = .exact
|
||||
) async -> Bool {
|
||||
if criteria.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "elementMatchesCriteria: Criteria dictionary is empty. Element '\\(element.briefDescription(option: .raw))' is considered a match by default."))
|
||||
return true
|
||||
}
|
||||
|
||||
) -> Bool {
|
||||
for criterion in criteria {
|
||||
let effectiveMatchType = criterion.match_type.flatMap { JSONPathHintComponent.MatchType(rawValue: $0) } ?? matchType
|
||||
if await !matchSingleCriterion(element: element, key: criterion.attribute, expectedValue: criterion.value, matchType: effectiveMatchType, elementDescriptionForLog: element.briefDescription(option: .raw)) {
|
||||
let effectiveMatchType = criterion.match_type ?? matchType
|
||||
if !matchSingleCriterion(element: element, key: criterion.attribute, expectedValue: criterion.value, matchType: effectiveMatchType, elementDescriptionForLog: element.briefDescription(option: ValueFormatOption.raw)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "elementMatchesCriteria: Element '\\(element.briefDescription(option: .raw))' MATCHED ALL \\(criteria.count) criteria: \\(criteria)."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "elementMatchesAllCriteria: Element '\(element.briefDescription(option: ValueFormatOption.raw))' MATCHED ALL \(criteria.count) criteria: \(criteria)."))
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func elementMatchesAnyCriterion(
|
||||
element: Element,
|
||||
criteria: [Criterion],
|
||||
matchType: JSONPathHintComponent.MatchType = .exact
|
||||
) -> Bool {
|
||||
if criteria.isEmpty { // If there are no criteria, it's vacuously false that any criterion matches.
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "elementMatchesAnyCriterion: No criteria provided. Returning false."))
|
||||
return false
|
||||
}
|
||||
for criterion in criteria {
|
||||
let effectiveMatchType = criterion.match_type ?? matchType // Use criterion's own match_type if present, else the overall one.
|
||||
if matchSingleCriterion(element: element, key: criterion.attribute, expectedValue: criterion.value, matchType: effectiveMatchType, elementDescriptionForLog: element.briefDescription(option: ValueFormatOption.raw)) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "elementMatchesAnyCriterion: Element '\(element.briefDescription(option: ValueFormatOption.raw))' MATCHED criterion: \(criterion)."))
|
||||
return true // Found one criterion that matches
|
||||
}
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "elementMatchesAnyCriterion: Element '\(element.briefDescription(option: ValueFormatOption.raw))' DID NOT MATCH ANY of \(criteria.count) criteria: \(criteria)."))
|
||||
return false
|
||||
}
|
||||
|
||||
// Overload for backward compatibility with dictionary
|
||||
@MainActor
|
||||
public func elementMatchesCriteria(
|
||||
_ element: Element,
|
||||
criteria: [String: String],
|
||||
matchType: JSONPathHintComponent.MatchType = .exact
|
||||
) async -> Bool {
|
||||
) -> Bool {
|
||||
let criterionArray = criteria.map { key, value in
|
||||
Criterion(attribute: key, value: value, match_type: nil)
|
||||
Criterion(attribute: key, value: value, matchType: nil)
|
||||
}
|
||||
return await elementMatchesCriteria(element, criteria: criterionArray, matchType: matchType)
|
||||
return elementMatchesAllCriteria(element: element, criteria: criterionArray, matchType: matchType)
|
||||
}
|
||||
|
||||
// MARK: - Single Criterion Matching Logic
|
||||
@ -106,33 +122,56 @@ internal func matchSingleCriterion(
|
||||
expectedValue: String,
|
||||
matchType: JSONPathHintComponent.MatchType,
|
||||
elementDescriptionForLog: String
|
||||
) async -> Bool {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC: Matching key '\\(key)' (expected: '\\(expectedValue)', type: \\(matchType.rawValue)) on \\(elementDescriptionForLog)"))
|
||||
) -> Bool {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC: Matching key '\(key)' (expected: '\(expectedValue)', type: \(matchType.rawValue)) on \(elementDescriptionForLog)"))
|
||||
let comparisonResult: Bool
|
||||
|
||||
switch key.lowercased() {
|
||||
case AXAttributeNames.kAXRoleAttribute.lowercased(), "role":
|
||||
return compareStrings(element.role(), expectedValue, matchType, attributeName: AXAttributeNames.kAXRoleAttribute, elementDescriptionForLog: elementDescriptionForLog)
|
||||
let actual = element.role()
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC/Role: Actual='\(actual ?? "nil")'"))
|
||||
if actual == AXRoleNames.kAXTextAreaRole {
|
||||
let domClassList = element.attribute(Attribute<Any>(AXAttributeNames.kAXDOMClassListAttribute))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .info, message: "SearchCrit/MSC/Role: ELEMENT IS AXTextArea. Its AXDOMClassList is: \(String(describing: domClassList))"))
|
||||
}
|
||||
comparisonResult = compareStrings(actual, expectedValue, matchType, caseSensitive: false, attributeName: AXAttributeNames.kAXRoleAttribute, elementDescriptionForLog: elementDescriptionForLog)
|
||||
case AXAttributeNames.kAXSubroleAttribute.lowercased(), "subrole":
|
||||
return compareStrings(element.subrole(), expectedValue, matchType, attributeName: AXAttributeNames.kAXSubroleAttribute, elementDescriptionForLog: elementDescriptionForLog)
|
||||
let actual = element.subrole()
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC/Subrole: Actual='\(actual ?? "nil")'"))
|
||||
comparisonResult = compareStrings(actual, expectedValue, matchType, caseSensitive: false, attributeName: AXAttributeNames.kAXSubroleAttribute, elementDescriptionForLog: elementDescriptionForLog)
|
||||
case AXAttributeNames.kAXIdentifierAttribute.lowercased(), "identifier", "id":
|
||||
return compareStrings(element.identifier(), expectedValue, matchType, attributeName: AXAttributeNames.kAXIdentifierAttribute, elementDescriptionForLog: elementDescriptionForLog)
|
||||
let actual = element.identifier()
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC/ID: Actual='\(actual ?? "nil")'"))
|
||||
comparisonResult = compareStrings(actual, expectedValue, matchType, caseSensitive: true, attributeName: AXAttributeNames.kAXIdentifierAttribute, elementDescriptionForLog: elementDescriptionForLog)
|
||||
case "pid":
|
||||
return matchPidCriterion(element: element, expectedValue: expectedValue, elementDescriptionForLog: elementDescriptionForLog)
|
||||
comparisonResult = matchPidCriterion(element: element, expectedValue: expectedValue, elementDescriptionForLog: elementDescriptionForLog)
|
||||
case AXAttributeNames.kAXDOMClassListAttribute.lowercased(), "domclasslist", "classlist":
|
||||
return await matchDomClassListCriterion(element: element, expectedValue: expectedValue, matchType: matchType, elementDescriptionForLog: elementDescriptionForLog)
|
||||
let actualRaw = element.attribute(Attribute<Any>(AXAttributeNames.kAXDOMClassListAttribute))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC/DOMClassList: ActualRaw='\(String(describing: actualRaw))'"))
|
||||
comparisonResult = matchDomClassListCriterion(element: element, expectedValue: expectedValue, matchType: matchType, elementDescriptionForLog: elementDescriptionForLog)
|
||||
case AXMiscConstants.isIgnoredAttributeKey.lowercased(), "isignored", "ignored":
|
||||
return matchIsIgnoredCriterion(element: element, expectedValue: expectedValue, elementDescriptionForLog: elementDescriptionForLog)
|
||||
comparisonResult = matchIsIgnoredCriterion(element: element, expectedValue: expectedValue, elementDescriptionForLog: elementDescriptionForLog)
|
||||
case AXMiscConstants.computedNameAttributeKey.lowercased(), "computedname", "name":
|
||||
return await matchComputedNameAttributes(element: element, expectedValue: expectedValue, matchType: matchType, attributeName: AXMiscConstants.computedNameAttributeKey, elementDescriptionForLog: elementDescriptionForLog)
|
||||
comparisonResult = matchComputedNameAttributes(element: element, expectedValue: expectedValue, matchType: matchType, attributeName: AXMiscConstants.computedNameAttributeKey, elementDescriptionForLog: elementDescriptionForLog)
|
||||
case "computednamewithvalue", "namewithvalue":
|
||||
return await matchComputedNameAttributes(element: element, expectedValue: expectedValue, matchType: matchType, attributeName: "computedNameWithValue", elementDescriptionForLog: elementDescriptionForLog, includeValueInComputedName: true)
|
||||
comparisonResult = matchComputedNameAttributes(element: element, expectedValue: expectedValue, matchType: matchType, attributeName: "computedNameWithValue", elementDescriptionForLog: elementDescriptionForLog, includeValueInComputedName: true)
|
||||
default:
|
||||
guard let actualValue: String = element.attribute(Attribute(key)) else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC: Attribute '\\(key)' not found or nil on \\(elementDescriptionForLog). No match."))
|
||||
guard let actualValueAny: Any = element.attribute(Attribute(key)) else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC/Default: Attribute '\(key)' not found or nil on \(elementDescriptionForLog). No match."))
|
||||
return false
|
||||
}
|
||||
return compareStrings(actualValue, expectedValue, matchType, attributeName: key, elementDescriptionForLog: elementDescriptionForLog)
|
||||
let actualValueString: String
|
||||
if let str = actualValueAny as? String {
|
||||
actualValueString = str
|
||||
} else {
|
||||
actualValueString = "\(actualValueAny)"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC/Default: Attribute '\(key)' on \(elementDescriptionForLog) was not String (type: \(type(of: actualValueAny))), using string description: '\(actualValueString)' for comparison."))
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC/Default: Attribute '\(key)', Actual='\(actualValueString)'"))
|
||||
comparisonResult = compareStrings(actualValueString, expectedValue, matchType, caseSensitive: true, attributeName: key, elementDescriptionForLog: elementDescriptionForLog)
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/MSC: Key '\(key)', Expected='\(expectedValue)', MatchType='\(matchType.rawValue)', Result=\(comparisonResult) on \(elementDescriptionForLog)."))
|
||||
return comparisonResult
|
||||
}
|
||||
|
||||
// MARK: - Specific Criterion Matchers
|
||||
@ -142,27 +181,27 @@ private func matchPidCriterion(element: Element, expectedValue: String, elementD
|
||||
let expectedPid = expectedValue
|
||||
if element.role() == AXRoleNames.kAXApplicationRole {
|
||||
guard let actualPid_t = element.pid() else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \\(elementDescriptionForLog) (app role) failed to provide PID. No match."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \(elementDescriptionForLog) (app role) failed to provide PID. No match."))
|
||||
return false
|
||||
}
|
||||
if String(actualPid_t) == expectedPid {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \\(elementDescriptionForLog) (app role) PID \\(actualPid_t) MATCHES expected \\(expectedPid)."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \(elementDescriptionForLog) (app role) PID \(actualPid_t) MATCHES expected \(expectedPid)."))
|
||||
return true
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \\(elementDescriptionForLog) (app role) PID \\(actualPid_t) MISMATCHES expected \\(expectedPid)."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \(elementDescriptionForLog) (app role) PID \(actualPid_t) MISMATCHES expected \(expectedPid)."))
|
||||
return false
|
||||
}
|
||||
}
|
||||
guard let actualPid_t = element.pid() else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \\(elementDescriptionForLog) failed to provide PID. No match."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \(elementDescriptionForLog) failed to provide PID. No match."))
|
||||
return false
|
||||
}
|
||||
let actualPidString = String(actualPid_t)
|
||||
if actualPidString == expectedPid {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \\(elementDescriptionForLog) PID \\(actualPidString) MATCHES expected \\(expectedPid)."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \(elementDescriptionForLog) PID \(actualPidString) MATCHES expected \(expectedPid)."))
|
||||
return true
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \\(elementDescriptionForLog) PID \\(actualPidString) MISMATCHES expected \\(expectedPid)."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/PID: \(elementDescriptionForLog) PID \(actualPidString) MISMATCHES expected \(expectedPid)."))
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -172,18 +211,18 @@ private func matchIsIgnoredCriterion(element: Element, expectedValue: String, el
|
||||
let actualIsIgnored: Bool = element.isIgnored()
|
||||
let expectedBool = (expectedValue.lowercased() == "true")
|
||||
if actualIsIgnored == expectedBool {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/IsIgnored: \\(elementDescriptionForLog) actual ('\\(actualIsIgnored)\') MATCHES expected ('\\(expectedBool)\')."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/IsIgnored: \(elementDescriptionForLog) actual ('\(actualIsIgnored)') MATCHES expected ('\(expectedBool)')."))
|
||||
return true
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/IsIgnored: \\(elementDescriptionForLog) actual ('\\(actualIsIgnored)\') MISMATCHES expected ('\\(expectedBool)\')."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/IsIgnored: \(elementDescriptionForLog) actual ('\(actualIsIgnored)') MISMATCHES expected ('\(expectedBool)')."))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func matchDomClassListCriterion(element: Element, expectedValue: String, matchType: JSONPathHintComponent.MatchType, elementDescriptionForLog: String) async -> Bool {
|
||||
private func matchDomClassListCriterion(element: Element, expectedValue: String, matchType: JSONPathHintComponent.MatchType, elementDescriptionForLog: String) -> Bool {
|
||||
guard let domClassListValue: Any = element.attribute(Attribute(AXAttributeNames.kAXDOMClassListAttribute)) else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \\(elementDescriptionForLog) attribute was nil. No match."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \(elementDescriptionForLog) attribute was nil. No match."))
|
||||
return false
|
||||
}
|
||||
|
||||
@ -194,44 +233,64 @@ private func matchDomClassListCriterion(element: Element, expectedValue: String,
|
||||
matchFound = classListArray.contains(expectedValue)
|
||||
case .contains:
|
||||
matchFound = classListArray.contains { $0.localizedCaseInsensitiveContains(expectedValue) }
|
||||
case .containsAny:
|
||||
let expectedParts = expectedValue.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
matchFound = classListArray.contains { actualPart in
|
||||
expectedParts.contains { expectedPart in actualPart.localizedCaseInsensitiveContains(expectedPart) }
|
||||
}
|
||||
case .prefix:
|
||||
matchFound = classListArray.contains { $0.hasPrefix(expectedValue) }
|
||||
case .suffix:
|
||||
matchFound = classListArray.contains { $0.hasSuffix(expectedValue) }
|
||||
case .regex:
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "SearchCrit/DOMClass: Regex match type not yet implemented for array. Defaulting to false."))
|
||||
matchFound = false
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: Regex matching for array of classes. Element: \(elementDescriptionForLog) Expected: \(expectedValue)."))
|
||||
matchFound = classListArray.contains { item in
|
||||
if let _ = item.range(of: expectedValue, options: .regularExpression) { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \\(elementDescriptionForLog) (Array: \\(classListArray)) match type '\\(matchType.rawValue)\' with '\\(expectedValue)\': \\(matchFound)."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \(elementDescriptionForLog) (Array: \(classListArray)) match type '\(matchType.rawValue)' with '\(expectedValue)' resolved to \(matchFound)."))
|
||||
} else if let classListString = domClassListValue as? String {
|
||||
let classes = classListString.split(separator: " ").map(String.init)
|
||||
switch matchType {
|
||||
case .exact:
|
||||
matchFound = classListString.split(separator: " ").map(String.init).contains(expectedValue)
|
||||
matchFound = classes.contains(expectedValue)
|
||||
case .contains:
|
||||
matchFound = classListString.localizedCaseInsensitiveContains(expectedValue)
|
||||
case .containsAny:
|
||||
let expectedParts = expectedValue.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
matchFound = expectedParts.contains { classListString.localizedCaseInsensitiveContains($0) }
|
||||
case .prefix:
|
||||
matchFound = classes.contains { $0.hasPrefix(expectedValue) }
|
||||
case .suffix:
|
||||
matchFound = classes.contains { $0.hasSuffix(expectedValue) }
|
||||
case .regex:
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "SearchCrit/DOMClass: Regex match type not yet implemented for string. Defaulting to false."))
|
||||
matchFound = false
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: Regex matching for space-separated class string. Element: \(elementDescriptionForLog) Expected: \(expectedValue)."))
|
||||
matchFound = classes.contains { item in
|
||||
if let _ = item.range(of: expectedValue, options: .regularExpression) { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \\(elementDescriptionForLog) (String: '\\(classListString)\') match type '\\(matchType.rawValue)\' with '\\(expectedValue)\': \\(matchFound)."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \(elementDescriptionForLog) (String: '\(classListString)') match type '\(matchType.rawValue)' with '\(expectedValue)' resolved to \(matchFound)."))
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \\(elementDescriptionForLog) attribute was not [String] or String. Type: \\(type(of: domClassListValue)). No match."))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \(elementDescriptionForLog) attribute was not [String] or String (type: \(type(of: domClassListValue))). No match."))
|
||||
return false
|
||||
}
|
||||
|
||||
if matchFound {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \(elementDescriptionForLog) MATCHED expected '\(expectedValue)' with type '\(matchType.rawValue)'. Classes: '\(domClassListValue)'"))
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/DOMClass: \(elementDescriptionForLog) MISMATCHED expected '\(expectedValue)' with type '\(matchType.rawValue)'. Classes: '\(domClassListValue)'"))
|
||||
}
|
||||
return matchFound
|
||||
}
|
||||
|
||||
// MARK: - Computed Name Matcher Helper
|
||||
|
||||
@MainActor
|
||||
private func matchComputedNameAttributes(
|
||||
element: Element,
|
||||
expectedValue: String,
|
||||
matchType: JSONPathHintComponent.MatchType,
|
||||
attributeName: String,
|
||||
elementDescriptionForLog: String,
|
||||
includeValueInComputedName: Bool = false
|
||||
) async -> Bool {
|
||||
private func matchComputedNameAttributes(element: Element, expectedValue: String, matchType: JSONPathHintComponent.MatchType, attributeName: String, elementDescriptionForLog: String, includeValueInComputedName: Bool = false) -> Bool {
|
||||
let computedName = element.computedName()
|
||||
|
||||
|
||||
if includeValueInComputedName {
|
||||
// For computedNameWithValue, we might need to include the value attribute
|
||||
if let value = element.value() as? String {
|
||||
let combinedName = (computedName ?? "") + " " + value
|
||||
return compareStrings(combinedName, expectedValue, matchType, attributeName: attributeName, elementDescriptionForLog: elementDescriptionForLog)
|
||||
@ -241,32 +300,110 @@ private func matchComputedNameAttributes(
|
||||
return compareStrings(computedName, expectedValue, matchType, attributeName: attributeName, elementDescriptionForLog: elementDescriptionForLog)
|
||||
}
|
||||
|
||||
// MARK: - Value Comparison Helper
|
||||
// MARK: - String Comparison Logic
|
||||
|
||||
@MainActor
|
||||
internal func compareStrings(_ actual: String?, _ expected: String, _ matchType: JSONPathHintComponent.MatchType, attributeName: String, elementDescriptionForLog: String) -> Bool {
|
||||
guard let actual = actual else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/Compare: '\\(attributeName)\' on \\(elementDescriptionForLog): Actual value is nil. Expected '\\(expected)'. No match."))
|
||||
return false
|
||||
public func compareStrings(
|
||||
_ actualValueOptional: String?,
|
||||
_ expectedValue: String,
|
||||
_ matchType: JSONPathHintComponent.MatchType,
|
||||
caseSensitive: Bool = true,
|
||||
attributeName: String,
|
||||
elementDescriptionForLog: String
|
||||
) -> Bool {
|
||||
guard let actualValue = actualValueOptional, !actualValue.isEmpty else {
|
||||
let isEmptyMatch = expectedValue.isEmpty && matchType == .exact
|
||||
|
||||
if isEmptyMatch {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/Compare: '\(attributeName)' on \(elementDescriptionForLog): Actual is nil/empty, Expected is empty. MATCHED with type '\(matchType.rawValue)'."))
|
||||
return true
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/Compare: Attribute '\(attributeName)' on \(elementDescriptionForLog) (actual: nil/empty, expected: '\(expectedValue)', type: \(matchType.rawValue)) -> MISMATCH"))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let comparisonResult: Bool
|
||||
let finalActual = caseSensitive ? actualValue : actualValue.lowercased()
|
||||
let finalExpected = caseSensitive ? expectedValue : expectedValue.lowercased()
|
||||
var result = false
|
||||
|
||||
switch matchType {
|
||||
case .exact:
|
||||
comparisonResult = (actual == expected)
|
||||
result = (finalActual.localizedCompare(finalExpected) == .orderedSame)
|
||||
case .contains:
|
||||
comparisonResult = actual.localizedCaseInsensitiveContains(expected)
|
||||
result = finalActual.contains(finalExpected)
|
||||
case .regex:
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .warning, message: "SearchCrit/Compare: Regex match type not yet implemented for attribute '\\(attributeName)\'. Defaulting to false."))
|
||||
comparisonResult = false
|
||||
result = (finalActual.range(of: finalExpected, options: .regularExpression) != nil)
|
||||
case .prefix:
|
||||
result = finalActual.hasPrefix(finalExpected)
|
||||
case .suffix:
|
||||
result = finalActual.hasSuffix(finalExpected)
|
||||
case .containsAny:
|
||||
let expectedSubstrings = finalExpected.split(separator: ",").map { String($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
|
||||
if expectedSubstrings.isEmpty && finalActual.isEmpty {
|
||||
result = true
|
||||
} else {
|
||||
result = expectedSubstrings.contains { substring in
|
||||
finalActual.contains(substring)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if comparisonResult {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/Compare: '\\(attributeName)\' on \\(elementDescriptionForLog): Actual ('\\(actual)\') MATCHED Expected ('\\(expected)\') with type '\\(matchType.rawValue)\'."))
|
||||
} else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/Compare: '\\(attributeName)\' on \\(elementDescriptionForLog): Actual ('\\(actual)\') MISMATCHED Expected ('\\(expected)\') with type '\\(matchType.rawValue)\'."))
|
||||
}
|
||||
return comparisonResult
|
||||
let matchStatus = result ? "MATCH" : "MISMATCH"
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchCrit/Compare: Attribute '\(attributeName)' on \(elementDescriptionForLog) (actual: '\(actualValue)', expected: '\(expectedValue)', type: \(matchType.rawValue), caseSensitive: \(caseSensitive)) -> \(matchStatus)"))
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Functions using undefined types (SearchCriteria, ProcessMatcherProtocol)
|
||||
// These functions are commented out until the required types are defined
|
||||
|
||||
/*
|
||||
@MainActor
|
||||
public func evaluateElementAgainstCriteria(
|
||||
_ element: Element,
|
||||
criteria: SearchCriteria,
|
||||
appIdentifier: String?,
|
||||
processMatcher: ProcessMatcherProtocol
|
||||
) -> (isMatch: Bool, logs: [AXLogEntry]) {
|
||||
var logs: [AXLogEntry] = [] // Changed from axDebugLog to aggregated logs
|
||||
|
||||
// Check if the app identifier matches, if provided and different from current app
|
||||
if let criteriaAppIdentifier = criteria.appIdentifier,
|
||||
let currentAppIdentifier = appIdentifier,
|
||||
criteriaAppIdentifier != currentAppIdentifier
|
||||
{
|
||||
logs.append(AXLogEntry(level: .debug, message: "SearchCriteriaUtils: Element \(element.briefDescription(option: ValueFormatOption.smart)) app mismatch. Criteria wants '\(criteriaAppIdentifier)', current is '\(currentAppIdentifier)'. No match."))
|
||||
return (false, logs) // Early exit if app ID doesn't match
|
||||
}
|
||||
|
||||
// Check basic properties first (role, subrole, identifier, title, value using direct attribute calls)
|
||||
if let criteriaRole = criteria.role, element.role() != criteriaRole { // role() is sync
|
||||
logs.append(AXLogEntry(level: .debug, message: "SearchCriteriaUtils: Element \(element.briefDescription(option: ValueFormatOption.smart)) role mismatch. Expected '\(criteriaRole)', got '\(element.role() ?? "nil")'."))
|
||||
return (false, logs)
|
||||
}
|
||||
|
||||
// If all checks passed
|
||||
logs.append(AXLogEntry(level: .debug, message: "SearchCriteriaUtils: Element \(element.briefDescription(option: ValueFormatOption.smart)) matches all criteria for app '\(appIdentifier ?? "any")'."))
|
||||
return (true, logs)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func elementMatchesAnyCriteria(
|
||||
_ element: Element,
|
||||
criteriaList: [SearchCriteria],
|
||||
appIdentifier: String?,
|
||||
processMatcher: ProcessMatcherProtocol
|
||||
) -> (isMatch: Bool, logs: [AXLogEntry]) {
|
||||
var overallLogs: [AXLogEntry] = []
|
||||
for criteria in criteriaList {
|
||||
let result = evaluateElementAgainstCriteria(element, criteria: criteria, appIdentifier: appIdentifier, processMatcher: processMatcher)
|
||||
overallLogs.append(contentsOf: result.logs)
|
||||
if result.isMatch {
|
||||
overallLogs.append(AXLogEntry(level: .debug, message: "SearchCriteriaUtils: Element \(element.briefDescription(option: ValueFormatOption.smart)) matched one of the criteria for app '\(appIdentifier ?? "any")'."))
|
||||
return (true, overallLogs)
|
||||
}
|
||||
}
|
||||
overallLogs.append(AXLogEntry(level: .debug, message: "SearchCriteriaUtils: Element \(element.briefDescription(option: ValueFormatOption.smart)) did not match any of the criteria for app '\(appIdentifier ?? "any")'."))
|
||||
return (false, overallLogs)
|
||||
}
|
||||
*/
|
||||
|
||||
176
Sources/AXorcist/Utils/AXObserverManager.swift
Normal file
176
Sources/AXorcist/Utils/AXObserverManager.swift
Normal file
@ -0,0 +1,176 @@
|
||||
import ApplicationServices
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
public class AXObserverManager {
|
||||
// Singleton instance
|
||||
public static let shared = AXObserverManager()
|
||||
|
||||
// Typealias for notification callback - matches AXObserverCallbackWithInfo but without refcon
|
||||
public typealias AXNotificationCallback = (AXObserver, AXUIElement, CFString, CFDictionary?) -> Void
|
||||
|
||||
// Error types
|
||||
public enum ObserverError: Error {
|
||||
case couldNotCreateObserver
|
||||
case addNotificationFailed(AXError)
|
||||
case other(String)
|
||||
}
|
||||
|
||||
// Private storage for observers and callbacks
|
||||
private struct ObserverInfo {
|
||||
let observer: AXObserver
|
||||
let runLoopSource: CFRunLoopSource
|
||||
var callbacks: [CFString: AXNotificationCallback] = [:]
|
||||
}
|
||||
|
||||
private var observers: [ObjectIdentifier: ObserverInfo] = [:]
|
||||
private let observerLock = NSLock()
|
||||
|
||||
private init() {}
|
||||
|
||||
// Add observer for an element and notification
|
||||
public func addObserver(for element: Element, notification: AXNotification, callback: @escaping AXNotificationCallback) throws {
|
||||
let elementId = ObjectIdentifier(element.underlyingElement as AnyObject)
|
||||
|
||||
observerLock.lock()
|
||||
defer { observerLock.unlock() }
|
||||
|
||||
// Check if we already have an observer for this element
|
||||
if var observerInfo = observers[elementId] {
|
||||
// Add the callback for this notification
|
||||
observerInfo.callbacks[notification.rawValue as CFString] = callback
|
||||
observers[elementId] = observerInfo
|
||||
|
||||
// Add the notification to the existing observer
|
||||
let error = AXObserverAddNotification(observerInfo.observer, element.underlyingElement, notification.rawValue as CFString, nil)
|
||||
if error != .success {
|
||||
axErrorLog("Failed to add notification: \(error)")
|
||||
throw ObserverError.addNotificationFailed(error)
|
||||
}
|
||||
} else {
|
||||
// Create a new observer
|
||||
guard let pid = element.pid() else {
|
||||
throw ObserverError.other("Could not get PID for element")
|
||||
}
|
||||
|
||||
var observer: AXObserver?
|
||||
|
||||
// Create the callback function for the observer
|
||||
let observerCallback: AXObserverCallbackWithInfo = { (observer, element, notification, userInfo, refcon) in
|
||||
// Since we can't pass refcon through AXObserverCreateWithInfoCallback,
|
||||
// we need to use a different approach to get back to the manager
|
||||
AXObserverManager.shared.handleNotification(observer: observer, element: element, notification: notification, userInfo: userInfo)
|
||||
}
|
||||
|
||||
let error = AXObserverCreateWithInfoCallback(
|
||||
pid,
|
||||
observerCallback,
|
||||
&observer
|
||||
)
|
||||
|
||||
guard error == .success, let observer = observer else {
|
||||
axErrorLog("Failed to create observer: \(error)")
|
||||
throw ObserverError.couldNotCreateObserver
|
||||
}
|
||||
|
||||
// Add the notification
|
||||
let addError = AXObserverAddNotification(observer, element.underlyingElement, notification.rawValue as CFString, nil)
|
||||
if addError != .success {
|
||||
axErrorLog("Failed to add notification: \(addError)")
|
||||
throw ObserverError.addNotificationFailed(addError)
|
||||
}
|
||||
|
||||
// Get the run loop source and add it to the main run loop
|
||||
let runLoopSource = AXObserverGetRunLoopSource(observer)
|
||||
CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .defaultMode)
|
||||
|
||||
// Store the observer info
|
||||
var callbacks: [CFString: AXNotificationCallback] = [:]
|
||||
callbacks[notification.rawValue as CFString] = callback
|
||||
let observerInfo = ObserverInfo(
|
||||
observer: observer,
|
||||
runLoopSource: runLoopSource,
|
||||
callbacks: callbacks
|
||||
)
|
||||
observers[elementId] = observerInfo
|
||||
|
||||
axDebugLog("Created observer for PID \(pid) with notification \(notification.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove observer for an element and notification
|
||||
public func removeObserver(for element: Element, notification: AXNotification) throws {
|
||||
let elementId = ObjectIdentifier(element.underlyingElement as AnyObject)
|
||||
|
||||
observerLock.lock()
|
||||
defer { observerLock.unlock() }
|
||||
|
||||
guard var observerInfo = observers[elementId] else {
|
||||
// No observer for this element
|
||||
return
|
||||
}
|
||||
|
||||
// Remove the notification from the observer
|
||||
let error = AXObserverRemoveNotification(observerInfo.observer, element.underlyingElement, notification.rawValue as CFString)
|
||||
if error != .success {
|
||||
axErrorLog("Failed to remove notification: \(error)")
|
||||
throw ObserverError.other("Failed to remove notification: \(error)")
|
||||
}
|
||||
|
||||
// Remove the callback
|
||||
observerInfo.callbacks.removeValue(forKey: notification.rawValue as CFString)
|
||||
|
||||
// If no more callbacks, remove the observer entirely
|
||||
if observerInfo.callbacks.isEmpty {
|
||||
// Remove from run loop
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), observerInfo.runLoopSource, .defaultMode)
|
||||
|
||||
// Invalidate the observer
|
||||
CFRunLoopSourceInvalidate(observerInfo.runLoopSource)
|
||||
|
||||
// Remove from our storage
|
||||
observers.removeValue(forKey: elementId)
|
||||
|
||||
axDebugLog("Removed observer for element")
|
||||
} else {
|
||||
// Update the observer info with removed callback
|
||||
observers[elementId] = observerInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming notifications
|
||||
private func handleNotification(observer: AXObserver, element: AXUIElement, notification: CFString, userInfo: CFDictionary?) {
|
||||
let elementId = ObjectIdentifier(element as AnyObject)
|
||||
|
||||
observerLock.lock()
|
||||
let observerInfo = observers[elementId]
|
||||
observerLock.unlock()
|
||||
|
||||
guard let observerInfo = observerInfo,
|
||||
let callback = observerInfo.callbacks[notification] else {
|
||||
axWarningLog("Received notification '\(notification)' but no callback found")
|
||||
return
|
||||
}
|
||||
|
||||
// Call the callback
|
||||
callback(observer, element, notification, userInfo)
|
||||
}
|
||||
|
||||
// Remove all observers
|
||||
public func removeAllObservers() {
|
||||
observerLock.lock()
|
||||
defer { observerLock.unlock() }
|
||||
|
||||
for (_, observerInfo) in observers {
|
||||
// Remove from run loop
|
||||
CFRunLoopRemoveSource(CFRunLoopGetMain(), observerInfo.runLoopSource, .defaultMode)
|
||||
|
||||
// Invalidate the observer
|
||||
CFRunLoopSourceInvalidate(observerInfo.runLoopSource)
|
||||
}
|
||||
|
||||
observers.removeAll()
|
||||
axDebugLog("Removed all observers")
|
||||
}
|
||||
}
|
||||
@ -4,51 +4,66 @@ import ApplicationServices
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
func extractTextFromElement(_ element: Element, maxDepth: Int = 2, currentDepth: Int = 0) async -> String? {
|
||||
// Basic attributes first
|
||||
var components: [String] = []
|
||||
|
||||
if let title: String = element.attribute(Attribute<String>.title) { components.append(title) }
|
||||
if let value: String = element.attribute(Attribute<String>(AXAttributeNames.kAXValueAttribute)) { components.append(value) }
|
||||
if let description: String = element.attribute(Attribute<String>.description) { components.append(description) }
|
||||
if let placeholder: String = element.attribute(Attribute<String>.placeholderValue) { components.append(placeholder) }
|
||||
if let help: String = element.attribute(Attribute<String>.help) { components.append(help) }
|
||||
|
||||
// If we have some text, or we've reached max depth, return
|
||||
if !components.isEmpty || currentDepth >= maxDepth {
|
||||
let joinedText = components.filter { !$0.isEmpty }.joined(separator: " ")
|
||||
if !joinedText.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "TextExtraction: Found text '\(joinedText)' at depth \(currentDepth) for element \(element.briefDescription(option: .smart))"))
|
||||
}
|
||||
return joinedText.isEmpty ? nil : joinedText
|
||||
public func extractTextFromElement(_ element: Element, maxDepth: Int = 5, currentDepth: Int = 0) -> String? {
|
||||
if currentDepth > maxDepth {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "extractTextFromElement: Max depth reached for element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Recursively check children if no text found yet and depth allows
|
||||
if let children = element.children() {
|
||||
// Attempt to get text from common attributes
|
||||
if let title = element.title(), !title.isEmpty { return title }
|
||||
if let value = element.value() as? String, !value.isEmpty { return value }
|
||||
if let description = element.descriptionText(), !description.isEmpty { return description }
|
||||
if let help = element.help(), !help.isEmpty { return help }
|
||||
|
||||
// If no direct text, try children
|
||||
var childrenText: [String] = []
|
||||
if let children = element.children() { // children() is now synchronous
|
||||
for child in children {
|
||||
if let childText = await extractTextFromElement(child, maxDepth: maxDepth, currentDepth: currentDepth + 1) {
|
||||
components.append(childText)
|
||||
if let childText = extractTextFromElement(child, maxDepth: maxDepth, currentDepth: currentDepth + 1) { // Removed await
|
||||
childrenText.append(childText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalText = components.filter { !$0.isEmpty }.joined(separator: " ")
|
||||
if !finalText.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "TextExtraction: Aggregated text '\(finalText)' at depth \(currentDepth) for element \(element.briefDescription(option: .smart))"))
|
||||
if !childrenText.isEmpty {
|
||||
return childrenText.joined(separator: " ")
|
||||
}
|
||||
return finalText.isEmpty ? nil : finalText
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "extractTextFromElement: No text found for element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func extractTextFromElementNonRecursive(_ element: Element) -> String? {
|
||||
// Try attributes that often hold primary text
|
||||
if let title = element.title(), !title.isEmpty { return title }
|
||||
if let value = element.value() as? String, !value.isEmpty { return value }
|
||||
if let description = element.descriptionText(), !description.isEmpty { return description }
|
||||
|
||||
// Fallback to a broader set if primary ones fail
|
||||
// if let placeholder = element.placeholderValue(), !placeholder.isEmpty { return placeholder }
|
||||
if let help = element.help(), !help.isEmpty { return help }
|
||||
|
||||
// Consider role description as a last resort if it's textual and meaningful
|
||||
// This might be too generic in many cases, so it's lower priority.
|
||||
// let roleDesc = element.roleDescription()
|
||||
// if let roleDesc = roleDesc, !roleDesc.isEmpty { return roleDesc }
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "extractTextFromElementNonRecursive: No direct text found for element: \(element.briefDescription(option: ValueFormatOption.smart))"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// More focused text extraction, typically used by handlers.
|
||||
@MainActor
|
||||
func getElementTextualContent(element: Element, includeChildren: Bool = false, maxDepth: Int = 1, currentDepth: Int = 0) async -> String? {
|
||||
func getElementTextualContent(element: Element, includeChildren: Bool = false, maxDepth: Int = 1, currentDepth: Int = 0) -> String? {
|
||||
var textPieces: [String] = []
|
||||
|
||||
// Prioritize attributes common for text content
|
||||
if let title: String = element.attribute(Attribute<String>.title) { textPieces.append(title) }
|
||||
if let value: String = element.attribute(Attribute<String>(AXAttributeNames.kAXValueAttribute)) { textPieces.append(value) }
|
||||
if let description: String = element.attribute(Attribute<String>.description) { textPieces.append(description) }
|
||||
if let placeholder: String = element.attribute(Attribute<String>.placeholderValue) { textPieces.append(placeholder) }
|
||||
// if let placeholder: String = element.attribute(Attribute<String>.placeholderValue) { textPieces.append(placeholder) }
|
||||
// Less common but potentially useful
|
||||
// if let help: String = element.attribute(Attribute.help) { textPieces.append(help) }
|
||||
// if let selectedText: String = element.attribute(Attribute.selectedText) { textPieces.append(selectedText) }
|
||||
@ -59,7 +74,8 @@ func getElementTextualContent(element: Element, includeChildren: Bool = false, m
|
||||
if let children = element.children() {
|
||||
var childTexts: [String] = []
|
||||
for child in children {
|
||||
if let childTextContent = await getElementTextualContent(element: child, includeChildren: true, maxDepth: maxDepth, currentDepth: currentDepth + 1) {
|
||||
// Recursive call is now synchronous
|
||||
if let childTextContent = getElementTextualContent(element: child, includeChildren: true, maxDepth: maxDepth, currentDepth: currentDepth + 1) {
|
||||
childTexts.append(childTextContent)
|
||||
}
|
||||
}
|
||||
@ -80,10 +96,10 @@ func getElementTextualContent(element: Element, includeChildren: Bool = false, m
|
||||
}
|
||||
|
||||
if !joinedDirectText.isEmpty {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "TextExtraction/Content: Extracted '\(joinedDirectText)' for element \(element.briefDescription(option: .smart)) (children included: \(includeChildren), depth: \(currentDepth))"))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "TextExtraction/Content: Extracted '\(joinedDirectText)' for element \(element.briefDescription(option: ValueFormatOption.smart)) (children included: \(includeChildren), depth: \(currentDepth))"))
|
||||
return joinedDirectText
|
||||
}
|
||||
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "TextExtraction/Content: No direct text found for \(element.briefDescription(option: .smart)) (children included: \(includeChildren), depth: \(currentDepth))"))
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "TextExtraction/Content: No direct text found for \(element.briefDescription(option: ValueFormatOption.smart)) (children included: \(includeChildren), depth: \(currentDepth))"))
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -1,230 +0,0 @@
|
||||
// TreeTraversal.swift - Defines protocols and classes for traversing the accessibility tree.
|
||||
|
||||
import ApplicationServices
|
||||
import Foundation
|
||||
// GlobalAXLogger is assumed available
|
||||
|
||||
// MARK: - Tree Traversal Protocols and Classes
|
||||
|
||||
// Protocol for a visitor that processes elements during traversal.
|
||||
@MainActor
|
||||
public protocol TreeVisitor {
|
||||
// Called for each element visited.
|
||||
func visit(element: Element, depth: Int, state: inout TraversalState) async -> TraversalAction
|
||||
}
|
||||
|
||||
// Represents the result of a visitor's processing of an element.
|
||||
public enum TraversalAction {
|
||||
case continueTraversal // Continue traversing children and siblings.
|
||||
case stop // Stop traversal immediately.
|
||||
case found(Element) // Element found, stop traversal.
|
||||
}
|
||||
|
||||
// Holds the current state of a traversal (e.g., depth).
|
||||
public struct TraversalState {
|
||||
public var currentDepth: Int
|
||||
public let maxDepth: Int
|
||||
public var elementsProcessed: Int
|
||||
public var branchesPruned: Int
|
||||
public let startTime: Date
|
||||
public let startElement: Element // The element from which traversal began.
|
||||
public let strictChildren: Bool // Whether to use strict children mode
|
||||
|
||||
public init(maxDepth: Int, startElement: Element, strictChildren: Bool = false) {
|
||||
self.currentDepth = 0
|
||||
self.maxDepth = maxDepth
|
||||
self.elementsProcessed = 0
|
||||
self.branchesPruned = 0
|
||||
self.startTime = Date()
|
||||
self.startElement = startElement
|
||||
self.strictChildren = strictChildren
|
||||
}
|
||||
|
||||
// Method to check if max depth has been exceeded.
|
||||
public func shouldStopForDepth() -> Bool {
|
||||
return currentDepth >= maxDepth
|
||||
}
|
||||
|
||||
public mutating func incrementProcessedCount() {
|
||||
elementsProcessed += 1
|
||||
}
|
||||
|
||||
public mutating func incrementPrunedCount() {
|
||||
branchesPruned += 1
|
||||
}
|
||||
}
|
||||
|
||||
// REMOVED: TreeTraverser class - keeping the struct version below which is more complete
|
||||
|
||||
// REMOVED: CollectAllVisitor class - keeping the more complete version below
|
||||
|
||||
// This is the actual data structure that CollectAllVisitor.collectedElements will contain.
|
||||
// Moved here from CollectAllHandler as it's part of the traversal output.
|
||||
public struct AXElementData: Codable {
|
||||
public var path: [String]?
|
||||
public var attributes: [String: AnyCodable]
|
||||
public var role: String?
|
||||
public var computedName: String?
|
||||
}
|
||||
|
||||
// MARK: - Unified Tree Traverser
|
||||
|
||||
@MainActor
|
||||
public struct TreeTraverser {
|
||||
private var visitedElements: Set<Element> = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public mutating func traverse(from startNode: Element, visitor: TreeVisitor, state: inout TraversalState) async -> Element? {
|
||||
let startNodeDesc = startNode.briefDescription(option: .smart)
|
||||
let logMaxDepth = state.maxDepth // Capture value for logging
|
||||
let logStrictChildren = state.strictChildren // Capture value for logging
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "[Traverse Entry] TreeTraverser.traverse starting from: \(startNodeDesc). MaxDepth: \(logMaxDepth), StrictChildren: \(logStrictChildren)"))
|
||||
visitedElements.removeAll()
|
||||
return await _traverse(currentElement: startNode, depth: 0, visitor: visitor, state: &state)
|
||||
}
|
||||
|
||||
private mutating func _traverse(currentElement: Element, depth: Int, visitor: TreeVisitor, state: inout TraversalState) async -> Element? {
|
||||
let currentDesc = currentElement.briefDescription(option: .smart)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "[_Traverse Entry] Visiting \(currentDesc) at depth \(depth)"))
|
||||
|
||||
if depth > state.maxDepth {
|
||||
let maxDepth = state.maxDepth
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Max depth (\(maxDepth)) reached at \(currentElement.briefDescription(option: .raw))"))
|
||||
return nil
|
||||
}
|
||||
|
||||
if visitedElements.contains(currentElement) {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Cycle detected at \(currentElement.briefDescription(option: .raw)). Skipping."))
|
||||
return nil
|
||||
}
|
||||
visitedElements.insert(currentElement)
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Visiting \(currentElement.briefDescription(option: .raw)) at depth \(depth)"))
|
||||
|
||||
switch await visitor.visit(element: currentElement, depth: depth, state: &state) {
|
||||
case .found(let foundElement):
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Element found by visitor: \(foundElement.briefDescription(option: .raw))"))
|
||||
return foundElement
|
||||
case .stop:
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "Traversal stopped by visitor at \(currentElement.briefDescription(option: .raw))"))
|
||||
return nil
|
||||
case .continueTraversal:
|
||||
break
|
||||
}
|
||||
|
||||
guard let children = currentElement.children(strict: state.strictChildren) else {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "No children for \(currentElement.briefDescription(option: .raw)) or error fetching them."))
|
||||
return nil
|
||||
}
|
||||
|
||||
for child in children {
|
||||
if let found = await _traverse(currentElement: child, depth: depth + 1, visitor: visitor, state: &state) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visitor Implementations
|
||||
|
||||
@MainActor
|
||||
public class CollectAllVisitor: TreeVisitor {
|
||||
private let attributesToFetch: [String]
|
||||
private let outputFormat: OutputFormat
|
||||
private let appElement: Element // Used for path generation relative to app root
|
||||
public var collectedElements: [AXElementData] = []
|
||||
// Default valueFormatOption for CollectAllVisitor if not specified otherwise
|
||||
private let valueFormatOption: ValueFormatOption
|
||||
private let filterCriteria: [Criterion]?
|
||||
|
||||
public init(attributesToFetch: [String], outputFormat: OutputFormat, appElement: Element, valueFormatOption: ValueFormatOption = .smart, filterCriteria: [Criterion]? = nil) {
|
||||
self.attributesToFetch = attributesToFetch
|
||||
self.outputFormat = outputFormat
|
||||
self.appElement = appElement
|
||||
self.valueFormatOption = valueFormatOption
|
||||
self.filterCriteria = filterCriteria
|
||||
}
|
||||
|
||||
// Convenience initializer for backward compatibility with dictionary
|
||||
public convenience init(attributesToFetch: [String], outputFormat: OutputFormat, appElement: Element, valueFormatOption: ValueFormatOption = .smart, filterCriteria: [String: String]?) {
|
||||
let criterionArray = filterCriteria?.map { key, value in
|
||||
Criterion(attribute: key, value: value, match_type: nil)
|
||||
}
|
||||
self.init(attributesToFetch: attributesToFetch, outputFormat: outputFormat, appElement: appElement, valueFormatOption: valueFormatOption, filterCriteria: criterionArray)
|
||||
}
|
||||
|
||||
public func visit(element: Element, depth: Int, state: inout TraversalState) async -> TraversalAction {
|
||||
if let criteria = self.filterCriteria, !criteria.isEmpty {
|
||||
let matchesFilter = await elementMatchesCriteria(element, criteria: criteria)
|
||||
if !matchesFilter {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "[CollectAllVisitor] Element \(element.briefDescription(option: .raw)) did NOT match filterCriteria. Skipping."))
|
||||
return .continueTraversal
|
||||
}
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "[CollectAllVisitor] Element \(element.briefDescription(option: .raw)) MATCHED filterCriteria."))
|
||||
}
|
||||
|
||||
let (fetchedAttrs, _) = await getElementAttributes(
|
||||
element: element,
|
||||
attributes: attributesToFetch,
|
||||
outputFormat: outputFormat,
|
||||
valueFormatOption: self.valueFormatOption
|
||||
)
|
||||
|
||||
let elementPath = element.generatePathArray(upTo: appElement)
|
||||
let role = element.role()
|
||||
let compName = element.computedName()
|
||||
|
||||
let elementData = AXElementData(
|
||||
path: elementPath,
|
||||
attributes: fetchedAttrs,
|
||||
role: role,
|
||||
computedName: compName
|
||||
)
|
||||
collectedElements.append(elementData)
|
||||
return .continueTraversal
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class SearchVisitor: TreeVisitor {
|
||||
private let locator: Locator
|
||||
private let requireAction: String?
|
||||
private var foundElement: Element?
|
||||
private var elementsProcessed: Int = 0
|
||||
|
||||
public init(locator: Locator, requireAction: String? = nil) {
|
||||
self.locator = locator
|
||||
self.requireAction = requireAction
|
||||
}
|
||||
|
||||
public func visit(element: Element, depth: Int, state: inout TraversalState) async -> TraversalAction {
|
||||
elementsProcessed += 1
|
||||
|
||||
if foundElement != nil {
|
||||
return .stop
|
||||
}
|
||||
|
||||
if depth == 0 && elementsProcessed == 1 {
|
||||
GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "SearchVisitor: Starting new search. Locator: \(self.locator.criteria)"))
|
||||
}
|
||||
|
||||
let matchStatus = await evaluateElementAgainstCriteria(
|
||||
element: element,
|
||||
locator: locator,
|
||||
actionToVerify: requireAction ?? locator.requireAction,
|
||||
depth: depth
|
||||
)
|
||||
|
||||
switch matchStatus {
|
||||
case .fullMatch:
|
||||
foundElement = element
|
||||
return .found(element)
|
||||
case .noMatch, .partialMatchActionMissing:
|
||||
return .continueTraversal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REMOVED: dLog global helper - use GlobalAXLogger directly.
|
||||
// REMOVED: Element extensions for briefDescriptionForDebug and pidString - use refactored Element methods.
|
||||
@ -52,6 +52,9 @@ struct ValueUnwrapper {
|
||||
let axVal = value as! AXValue
|
||||
let axValueType = axVal.valueType
|
||||
|
||||
// Log the AXValueType
|
||||
axDebugLog("ValueUnwrapper.unwrapAXValue: Encountered AXValue with type: \(axValueType) (rawValue: \(axValueType.rawValue))")
|
||||
|
||||
// Handle special boolean type
|
||||
if axValueType.rawValue == 4 { // kAXValueBooleanType (private)
|
||||
var boolResult: DarwinBoolean = false
|
||||
@ -61,7 +64,9 @@ struct ValueUnwrapper {
|
||||
}
|
||||
|
||||
// Use new AXValue extensions for cleaner unwrapping
|
||||
return axVal.value()
|
||||
let unwrappedExtensionValue = axVal.value()
|
||||
axDebugLog("ValueUnwrapper.unwrapAXValue: axVal.value() returned: \(String(describing: unwrappedExtensionValue)) for type: \(axValueType)")
|
||||
return unwrappedExtensionValue
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
// AXORCMain.swift - Main entry point for AXORC CLI
|
||||
|
||||
import ArgumentParser
|
||||
import AXorcist // For AXorcist instance
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
@preconcurrency import ArgumentParser
|
||||
import AXorcist // For AXorcist instance
|
||||
|
||||
// axorcVersion is now defined in AXORCModels.swift
|
||||
// let axorcVersion = "0.1.0-dev"
|
||||
|
||||
@main
|
||||
struct AXORCCommand: AsyncParsableCommand {
|
||||
struct AXORCCommand: @preconcurrency ParsableCommand {
|
||||
static let configuration: CommandConfiguration = CommandConfiguration(
|
||||
commandName: "axorc",
|
||||
abstract: "AXORC CLI - Handles JSON commands via various input methods. Version \(axorcVersion)"
|
||||
// Use axorcVersion from AXORCModels.swift or a shared constant place
|
||||
abstract: "AXORC CLI - Handles JSON commands via various input methods. Version \\(axorcVersion)"
|
||||
)
|
||||
|
||||
@Flag(name: .long, help: "Enable debug logging for the command execution.")
|
||||
@ -30,12 +34,13 @@ struct AXORCCommand: AsyncParsableCommand {
|
||||
var directPayload: String?
|
||||
|
||||
// Helper function to process and execute a CommandEnvelope
|
||||
private func processAndExecuteCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) async {
|
||||
@MainActor
|
||||
private func processAndExecuteCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) {
|
||||
if debugCLI {
|
||||
axDebugLog("Successfully parsed command: \(command.command) (ID: \(command.commandId))")
|
||||
}
|
||||
|
||||
let resultJsonString = await CommandExecutor.execute(
|
||||
let resultJsonString = CommandExecutor.execute(
|
||||
command: command,
|
||||
axorcist: axorcist,
|
||||
debugCLI: debugCLI
|
||||
@ -47,9 +52,9 @@ struct AXORCCommand: AsyncParsableCommand {
|
||||
var observerSetupSucceeded = false
|
||||
if let resultData = resultJsonString.data(using: .utf8) {
|
||||
do {
|
||||
if let json = try JSONSerialization.jsonObject(with: resultData, options: []) as? [String: Any],
|
||||
let success = json["success"] as? Bool,
|
||||
let status = json["status"] as? String {
|
||||
if let jsonOutput = try JSONSerialization.jsonObject(with: resultData, options: []) as? [String: Any],
|
||||
let success = jsonOutput["success"] as? Bool,
|
||||
let status = jsonOutput["status"] as? String {
|
||||
axInfoLog("AXORCMain: Parsed initial response for observe: success=\(success), status=\(status)")
|
||||
if success && status == "observer_started" {
|
||||
observerSetupSucceeded = true
|
||||
@ -68,34 +73,43 @@ struct AXORCCommand: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
if observerSetupSucceeded {
|
||||
axInfoLog("AXORCMain: Observer setup successful. Process will remain alive.")
|
||||
axInfoLog("AXORCMain: Observer setup successful. Process will remain alive by running current RunLoop.")
|
||||
#if DEBUG
|
||||
axInfoLog("AXORCMain: DEBUG mode - launching dedicated run-loop thread for observer.")
|
||||
Thread.detachNewThread { CFRunLoopRun() }
|
||||
axInfoLog("AXORCMain: DEBUG mode - main task entering infinite sleep loop.")
|
||||
while true {
|
||||
do { try await Task.sleep(nanoseconds: 3_600_000_000_000) }
|
||||
catch { axInfoLog("AXORCMain: Main task sleep interrupted."); break }
|
||||
}
|
||||
axInfoLog("AXORCMain: DEBUG mode - entering RunLoop.current.run() for observer.")
|
||||
RunLoop.current.run()
|
||||
axInfoLog("AXORCMain: DEBUG mode - RunLoop.current.run() finished.")
|
||||
#else
|
||||
fputs("{\"error\": \"The 'observe' command is intended for DEBUG builds or specific use cases and will not run indefinitely in this release build. Exiting.\"}\n", stderr)
|
||||
fputs("{\"error\": \"The 'observe' command is intended for DEBUG builds or specific use cases. In release, it sets up the observer but will not keep the process alive indefinitely by itself. Exiting normally after setup.\"}\n", stderr)
|
||||
fflush(stderr)
|
||||
Foundation.exit(EXIT_FAILURE)
|
||||
#endif
|
||||
} else {
|
||||
axErrorLog("AXORCMain: Observe command setup reported failure or result was not a success status. Exiting.")
|
||||
}
|
||||
} else {
|
||||
axClearLogs() // Clear logs for non-observe commands after execution
|
||||
axClearLogs()
|
||||
}
|
||||
}
|
||||
|
||||
mutating func run() async throws {
|
||||
// Configure GlobalAXLogger based on debug flag
|
||||
await GlobalAXLogger.shared.setLoggingEnabled(debug)
|
||||
await GlobalAXLogger.shared.setDetailLevel(debug ? .verbose : .minimal)
|
||||
@MainActor
|
||||
mutating func run() throws {
|
||||
fputs("AXORCMain.run: VERY FIRST LINE EXECUTED.\n", stderr)
|
||||
fflush(stderr)
|
||||
|
||||
GlobalAXLogger.shared.isLoggingEnabled = debug
|
||||
GlobalAXLogger.shared.detailLevel = debug ? .verbose : .minimal
|
||||
|
||||
// Confirm settings
|
||||
fputs("AXORCMain.run: CLI --debug flag is: \(debug). Logger enabled: \(GlobalAXLogger.shared.isLoggingEnabled).\n", stderr)
|
||||
fflush(stderr)
|
||||
|
||||
// <<< TEST LOGGING START >>>
|
||||
axErrorLog("AXORCMain.run: TEST ERROR LOG -- SHOULD ALWAYS APPEAR IN DEBUG OUTPUT IF LOGS ARE PRINTED")
|
||||
axDebugLog("AXORCMain.run: TEST DEBUG LOG -- SHOULD APPEAR IF CLI --debug IS ON")
|
||||
if debug {
|
||||
fputs("AXORCMain.run: STDERR - CLI --debug IS ON. TEST LOGGING.\n", stderr)
|
||||
}
|
||||
// <<< TEST LOGGING END >>>
|
||||
|
||||
// Parse input using InputHandler
|
||||
let inputResult = InputHandler.parseInput(
|
||||
stdin: stdin,
|
||||
file: file,
|
||||
@ -103,9 +117,10 @@ struct AXORCCommand: AsyncParsableCommand {
|
||||
directPayload: directPayload
|
||||
)
|
||||
|
||||
// Handle input errors
|
||||
let axorcistInstance = AXorcist.shared // Use the shared instance
|
||||
|
||||
if let error = inputResult.error {
|
||||
let collectedLogs = debug ? await GlobalAXLogger.shared.getLogsAsStrings(format: .text, includeTimestamps: true, includeLevels: true, includeDetails: true) : nil
|
||||
let collectedLogs = debug ? GlobalAXLogger.shared.getLogsAsStrings(format: .text) : 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) {
|
||||
print(jsonString)
|
||||
@ -116,7 +131,7 @@ struct AXORCCommand: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
guard let jsonStringFromInput = inputResult.jsonString else {
|
||||
let collectedLogs = debug ? await GlobalAXLogger.shared.getLogsAsStrings(format: .text, includeTimestamps: true, includeLevels: true, includeDetails: true) : nil
|
||||
let collectedLogs = debug ? GlobalAXLogger.shared.getLogsAsStrings(format: .text) : 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) {
|
||||
print(jsonStr)
|
||||
@ -128,37 +143,90 @@ struct AXORCCommand: AsyncParsableCommand {
|
||||
axDebugLog("AXORCMain Test: Received jsonStringFromInput: [\(jsonStringFromInput)] (length: \(jsonStringFromInput.count))")
|
||||
|
||||
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)
|
||||
processAndExecuteCommand(command: command, axorcist: axorcistInstance, 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
|
||||
let command = try decoder.decode(CommandEnvelope.self, from: data)
|
||||
axDebugLog("AXORCMain Test: Decode attempt 2: Successfully decoded as SINGLE CommandEnvelope.")
|
||||
await processAndExecuteCommand(command: command, axorcist: axorcist, debugCLI: debug)
|
||||
processAndExecuteCommand(command: command, axorcist: axorcistInstance, 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
|
||||
let errorResponse = ErrorResponse(commandId: "decode_error", error: "Failed to decode JSON input: \(singleDecodeError.localizedDescription)", debugLogs: debug ? axGetLogsAsStrings() : nil)
|
||||
if let jsonData = try? JSONEncoder().encode(errorResponse), let jsonErrorString = String(data: jsonData, encoding: .utf8) {
|
||||
print(jsonErrorString)
|
||||
} else {
|
||||
print("{\"error\": \"Failed to encode decode error response: \(singleDecodeError.localizedDescription)\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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
|
||||
let errorResponse = ErrorResponse(commandId: "data_conversion_error", error: "Failed to convert JSON string to data", debugLogs: debug ? axGetLogsAsStrings() : nil)
|
||||
if let jsonData = try? JSONEncoder().encode(errorResponse), let jsonErrorString = String(data: jsonData, encoding: .utf8) {
|
||||
print(jsonErrorString)
|
||||
} else {
|
||||
print("{\"error\": \"Failed to encode data conversion error response\"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// After processing all commands or if an error occurs
|
||||
if debug && commandShouldPrintLogsAtEnd() {
|
||||
let logMessages = axGetLogsAsStrings(format: .text)
|
||||
if !logMessages.isEmpty {
|
||||
fputs("\n--- Debug Logs (axorc run end) ---\n", stderr)
|
||||
for logMessage in logMessages {
|
||||
fputs(logMessage + "\n", stderr)
|
||||
}
|
||||
fputs("--- End Debug Logs ---\n", stderr)
|
||||
fflush(stderr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func commandShouldPrintLogsAtEnd() -> Bool {
|
||||
// This is a simplified check. A more robust way would be to check
|
||||
// the actual command type if it's available here.
|
||||
// For now, if stdin is true or json is provided, assume it might be an observe command.
|
||||
// This is imperfect.
|
||||
// Remove try? if InputHandler.parseInput is not throwing
|
||||
if let jsonString = InputHandler.parseInput(stdin: stdin, file: file, json: json, directPayload: directPayload).jsonString,
|
||||
let inputData = jsonString.data(using: .utf8) { // Corrected optional chaining and conditional binding
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
if let commands = try? decoder.decode([CommandEnvelope].self, from: inputData),
|
||||
commands.first?.command == .observe {
|
||||
return false
|
||||
}
|
||||
if let command = try? decoder.decode(CommandEnvelope.self, from: inputData),
|
||||
command.command == .observe {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ErrorResponse struct is now defined in AXORCModels.swift
|
||||
// struct ErrorResponse: Codable {
|
||||
// var commandId: String
|
||||
// var status: String = "error"
|
||||
// var error: String
|
||||
// var debugLogs: [String]?
|
||||
// }
|
||||
|
||||
// Removed placeholder GlobalAXLogger extension as it was moved/integrated previously
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,13 @@ import Foundation
|
||||
// MARK: - Version and Configuration
|
||||
let axorcVersion = "0.1.2a-config_fix"
|
||||
|
||||
// MARK: - Shared Error Detail
|
||||
|
||||
// Moved ErrorDetail to be a top-level struct
|
||||
struct ErrorDetail: Codable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
// MARK: - Response Models
|
||||
// These should align with structs in AXorcistIntegrationTests.swift
|
||||
|
||||
@ -32,9 +39,6 @@ struct SimpleSuccessResponse: Codable {
|
||||
struct ErrorResponse: Codable {
|
||||
let commandId: String
|
||||
var success: Bool = false // Default to false for errors
|
||||
struct ErrorDetail: Codable {
|
||||
let message: String
|
||||
}
|
||||
let error: ErrorDetail
|
||||
let debugLogs: [String]?
|
||||
|
||||
@ -72,7 +76,7 @@ struct QueryResponse: Codable {
|
||||
let success: Bool
|
||||
let command: String // Name of the command, e.g., "getFocusedElement"
|
||||
let data: AXElementForEncoding? // Contains the AX element's data, adapted for encoding
|
||||
let error: ErrorResponse.ErrorDetail?
|
||||
let error: ErrorDetail?
|
||||
let debugLogs: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@ -96,7 +100,7 @@ struct QueryResponse: Codable {
|
||||
self.data = nil
|
||||
}
|
||||
if let errorMsg = handlerResponse.error {
|
||||
self.error = ErrorResponse.ErrorDetail(message: errorMsg)
|
||||
self.error = ErrorDetail(message: errorMsg)
|
||||
} else {
|
||||
self.error = nil
|
||||
}
|
||||
@ -116,7 +120,7 @@ struct QueryResponse: Codable {
|
||||
self.command = command ?? "unknown"
|
||||
self.data = axElement != nil ? AXElementForEncoding(from: axElement!) : nil
|
||||
if let errorMessage = error {
|
||||
self.error = ErrorResponse.ErrorDetail(message: errorMessage)
|
||||
self.error = ErrorDetail(message: errorMessage)
|
||||
} else {
|
||||
self.error = nil
|
||||
}
|
||||
@ -138,6 +142,45 @@ struct BatchResponse: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// For batch operations that may have mixed results
|
||||
struct BatchQueryResponse: Codable {
|
||||
let commandId: String
|
||||
let status: String
|
||||
var message: String?
|
||||
var data: [AnyCodable?]?
|
||||
var errors: [String]?
|
||||
var debugLogs: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case commandId
|
||||
case status
|
||||
case message
|
||||
case data
|
||||
case errors
|
||||
case debugLogs
|
||||
}
|
||||
}
|
||||
|
||||
// Generic query response for commands (renamed to avoid conflict)
|
||||
struct GenericQueryResponse: Codable {
|
||||
let commandId: String
|
||||
let commandType: String
|
||||
let status: String
|
||||
let data: AnyCodable?
|
||||
let message: String?
|
||||
var debugLogs: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case commandId
|
||||
case commandType
|
||||
case status
|
||||
case data
|
||||
case message
|
||||
case debugLogs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Helper for DecodingError display
|
||||
extension DecodingError {
|
||||
var humanReadableDescription: String {
|
||||
|
||||
15
axorc_stderr.log
Normal file
15
axorc_stderr.log
Normal file
@ -0,0 +1,15 @@
|
||||
AXORCMain.run: VERY FIRST LINE EXECUTED.
|
||||
AXORCMain.run: CLI --debug flag is: true. Logger enabled: true.
|
||||
AXORCMain.run: STDERR - CLI --debug IS ON. TEST LOGGING.
|
||||
2025-05-27T00:52:06+0200 info AXorcist.ElementSearch : [AXorcist] FindTargetEl: START
|
||||
App: 'com.todesktop.230313mzl4w4u92'
|
||||
MaxDepth: 10
|
||||
Initial Criteria: [AXRole:AXTextArea, match:exact]
|
||||
PathHint (count: 0):
|
||||
-> nil
|
||||
2025-05-27T00:52:11+0200 warning AXorcist.ElementSearch : [AXorcist] FindTargetEl: No element found matching final criteria [AXRole:AXTextArea] starting from application root Role: AXApplication, Title: 'Cursor'.
|
||||
|
||||
--- Debug Logs (axorc run end) ---
|
||||
[2025-05-26T22:54:42.489Z] [DEBUG] [InputHandler.swift:21] [parseInput(stdin:file:json:directPayload:)] - InputHandler: Parsing input...
|
||||
[2025-05-26T22:54:42.489Z] [DEBUG] [InputHandler.swift:34] [parseInput(stdin:file:json:directPayload:)] - Using --json flag with payload of 720 characters.
|
||||
--- End Debug Logs ---
|
||||
18
run_axorc_with_json_content.sh
Executable file
18
run_axorc_with_json_content.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Read the entire content of the JSON file into a variable
|
||||
json_content=$(<"/Users/steipete/Projects/CodeLooper/query_cursor_input.json")
|
||||
|
||||
# Extract and print the command_id from the JSON content
|
||||
command_id=$(echo "$json_content" | jq -r .command_id)
|
||||
echo "DEBUG: command_id from file is: $command_id"
|
||||
|
||||
# Remove any previous debug log file
|
||||
rm -f axorc_stderr.log
|
||||
|
||||
# Execute axorc with the JSON content (not the file path) and debug flag
|
||||
# Redirect stderr to axorc_stderr.log
|
||||
./.build/arm64-apple-macosx/debug/axorc --json "$json_content" --debug 2>axorc_stderr.log
|
||||
|
||||
# Cat the stderr log content to stdout
|
||||
cat axorc_stderr.log
|
||||
Loading…
Reference in New Issue
Block a user