Add observe feature for cli and fix various json encoding issues

This commit is contained in:
Peter Steinberger 2025-05-25 19:59:17 +02:00
parent f5fb728228
commit 5cbe85e85a
14 changed files with 525 additions and 126 deletions

View File

@ -91,8 +91,130 @@ public class AXorcist {
}
return foundElement
}
// MARK: - Observe Command Handler
@MainActor
public func handleObserve(
for appIdentifierOrNil: String?,
notifications: [String], // These are AXNotification strings
includeElementDetails: [String],
watchChildren: Bool, // Parameter not used yet
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: ", "))")
guard let appElement = applicationElement(for: appIdentifier) else {
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Application not found: \(appIdentifier)")
return false
}
var subscriptionTokens: [AXObserverCenter.SubscriptionToken] = []
// This callback now captures necessary variables from the outer scope.
// It matches the AXNotificationSubscriptionHandler signature.
let observerCallback: AXNotificationSubscriptionHandler = {
// Captured: commandId, includeElementDetails, appIdentifier, appElement
obsPid, notificationNameString, rawObservedElement, nsUserInfo in
let observedElement = Element(rawObservedElement)
// Ensure appElement is valid for path generation, otherwise path might be too long/incorrect
let elementPath = observedElement.generatePathArray(upTo: appElement.pid() == obsPid ? appElement : nil)
let (attributes, _) = getElementAttributes(
element: observedElement,
attributes: includeElementDetails, // Captured
outputFormat: .smart
)
// Build a raw element dictionary (sanitized) using plain Swift types
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
}
// Build overall payload with primitive types after sanitization
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 {
// Ensure axNotificationName is valid before using it
guard let axNotificationName = AXNotification(rawValue: notificationNameString) else {
axErrorLog("[AXorcist.handleObserve][CmdID: \(commandId)] Invalid notification name string: \(notificationNameString). Skipping.")
continue // Skip to the next notification string
}
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, // Observe the application element itself
notification: axNotificationName, // Now safely unwrapped
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` (likely in a different file)
// still needs to be refactored to use GlobalAXLogger and remove its logging parameters.
// The call above anticipates this change.
// NOTE: The global function `findElementViaPathAndCriteria`

View File

@ -365,3 +365,4 @@ public enum AXMiscConstants {
public static let maxPathSegments = 20 // Limit for path segment generation to avoid infinite loops
// pathHintAttributeKey was for Element.swift's pathHint property, which is different from AXAttributeNames.kAXPathHintAttribute
}

View File

@ -1,6 +1,7 @@
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.
@ -125,7 +126,22 @@ public struct AnyCodable: Codable, @unchecked Sendable {
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)

View File

@ -22,6 +22,11 @@ public struct CommandEnvelope: Codable {
public let point: CGPoint? // Added for getElementAtPoint
public let pid: Int? // Added for getElementAtPoint (optional specific PID)
// Parameters for 'observe' command
public let notifications: [String]?
public let includeElementDetails: [String]?
public let watchChildren: Bool?
enum CodingKeys: String, CodingKey {
case commandId
case command
@ -39,6 +44,10 @@ public struct CommandEnvelope: Codable {
case subCommands
case point
case pid
// CodingKeys for observe parameters
case notifications
case includeElementDetails
case watchChildren
}
// Added a public initializer for convenience, matching fields.
@ -57,7 +66,12 @@ public struct CommandEnvelope: Codable {
actionValue: AnyCodable? = nil,
subCommands: [CommandEnvelope]? = nil,
point: CGPoint? = nil,
pid: Int? = nil) {
pid: Int? = nil,
// Init parameters for observe
notifications: [String]? = nil,
includeElementDetails: [String]? = nil,
watchChildren: Bool? = nil
) {
self.commandId = commandId
self.command = command
self.application = application
@ -74,6 +88,10 @@ 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
}
}

View File

@ -78,3 +78,4 @@ extension Element {
return setBooleanAttribute(AXAttributeNames.kAXHiddenAttribute, value: false)
}
}

View File

@ -13,13 +13,16 @@ extension Element {
var childCollector = ChildCollector() // ChildCollector will use GlobalAXLogger internally
print("[PRINT Element.children] Before collectDirectChildren for: \(self.briefDescription(option: .default))")
collectDirectChildren(collector: &childCollector)
print("[PRINT Element.children] After collectDirectChildren, collector has: \(childCollector.collectedChildrenCount()) unique children.")
if !strict { // Only collect alternatives if not strict
collectAlternativeChildren(collector: &childCollector)
collectApplicationWindows(collector: &childCollector)
collectAlternativeChildren(collector: &childCollector)
collectApplicationWindows(collector: &childCollector)
}
print("[PRINT Element.children] Before finalizeResults, collector has: \(childCollector.collectedChildrenCount()) unique children.")
let result = childCollector.finalizeResults()
axDebugLog("Final children count: \(result?.count ?? 0)")
return result
@ -44,19 +47,19 @@ extension Element {
if let directChildrenUI = childrenCFArray as? [AXUIElement] {
axDebugLog(
"[\(selfDescForLog)]: Successfully fetched and cast " +
"\(directChildrenUI.count) direct children."
"\(directChildrenUI.count) direct children."
)
collector.addChildren(from: directChildrenUI)
} else {
axDebugLog(
"[\(selfDescForLog)]: kAXChildrenAttribute was a CFArray but failed to cast " +
"to [AXUIElement]. TypeID: \(CFGetTypeID(childrenCFArray))"
"to [AXUIElement]. TypeID: \(CFGetTypeID(childrenCFArray))"
)
}
} else if let nonArrayValue = value {
axDebugLog(
"[\(selfDescForLog)]: kAXChildrenAttribute was not a CFArray. " +
"TypeID: \(CFGetTypeID(nonArrayValue)). Value: \(String(describing: nonArrayValue))"
"TypeID: \(CFGetTypeID(nonArrayValue)). Value: \(String(describing: nonArrayValue))"
)
} else {
axDebugLog("[\(selfDescForLog)]: kAXChildrenAttribute was nil despite .success error code.")
@ -80,7 +83,7 @@ extension Element {
]
axDebugLog(
"Using pruned attribute list (\(alternativeAttributes.count) items) " +
"to avoid heavy payloads for alternative children."
"to avoid heavy payloads for alternative children."
)
for attrName in alternativeAttributes {
@ -141,7 +144,7 @@ private struct ChildCollector {
if !limitReached {
axWarningLog(
"ChildCollector: Reached maximum children limit (\(maxChildrenPerElement)). " +
"No more children will be added for this element."
"No more children will be added for this element."
)
limitReached = true
}
@ -156,6 +159,11 @@ private struct ChildCollector {
}
}
// New public method to get the count of unique children
public func collectedChildrenCount() -> Int {
return uniqueChildrenSet.count
}
func finalizeResults() -> [Element]? { // Removed dLog param
if collectedChildren.isEmpty {
axDebugLog("ChildCollector: No children found after all collection methods.")

View File

@ -22,5 +22,6 @@ public enum CommandType: String, Codable {
case extractText
case ping
case getElementAtPoint
case observe
// Add future commands here, ensuring case matches JSON or provide explicit raw value
}

View File

@ -65,7 +65,7 @@ extension AXorcist {
case .ping:
return processPingCommand(subCmdID)
case .collectAll, .batch: // Recursive batch calls are not supported
case .collectAll, .batch, .observe:
return processUnsupportedCommand(subCommandEnvelope, subCmdID: subCmdID)
@unknown default:

View File

@ -93,7 +93,7 @@ extension AXorcist {
logCollectAllStart(params)
// Get app element
guard let appElement = await applicationElement(for: params.appIdentifier) else {
guard let appElement = applicationElement(for: params.appIdentifier) else {
return await createErrorResponse(
commandId: params.effectiveCommandId,
appIdentifier: params.appIdentifier,
@ -202,7 +202,7 @@ extension AXorcist {
let pathHintString = hint.joined(separator: " -> ")
axDebugLog("[CollectAll] Navigating to path hint: \(pathHintString)")
guard let navigatedElement = await navigateToElement(
guard let navigatedElement = navigateToElement(
from: appElement,
pathHint: hint,
maxDepth: AXMiscConstants.defaultMaxDepthSearch

View File

@ -24,35 +24,35 @@ private func elementMatchesAllCriteria(
for (key, expectedValue) in criteria {
if key == "PID" { // Special handling for PID
guard let actualPid_t = await element.pid() else { // Uses existing pid() -> pid_t?, await call
axDebugLog("Element [\\(elementDescriptionForLog)] failed to provide PID (for path component [\\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
axDebugLog("Element [\(elementDescriptionForLog)] failed to provide PID (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
return false
}
let actualPid = Int(actualPid_t) // Convert pid_t to Int for comparison
guard let expectedPid = Int(expectedValue) else {
axDebugLog("Element [\\(elementDescriptionForLog)] PID criteria '\\(expectedValue)' is not a valid Int (for path component [\\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
axDebugLog("Element [\(elementDescriptionForLog)] PID criteria '\(expectedValue)' is not a valid Int (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
return false
}
if actualPid != expectedPid {
axDebugLog("Element [\\(elementDescriptionForLog)] PID [\\(actualPid)] != expected [\\(expectedPid)] (for path component [\\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] != expected [\(expectedPid)] (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
return false
}
axDebugLog("Element [\\(elementDescriptionForLog)] PID [\\(actualPid)] == expected [\\(expectedPid)] (for path component [\\(pathComponentForLog)]). Criterion met.", file: #file, function: #function, line: #line)
axDebugLog("Element [\(elementDescriptionForLog)] PID [\(actualPid)] == expected [\(expectedPid)] (for path component [\(pathComponentForLog)]). Criterion met.", file: #file, function: #function, line: #line)
} else { // Handle other attributes as before
let fetchedAttributeValue: String? = await element.attribute(Attribute(key)) // await call
axDebugLog("PathNav/EMAC: For element [\\(elementDescriptionForLog)], component [\\(pathComponentForLog)], attr [\\(key)], fetched value is: [\\(String(describing: fetchedAttributeValue))]. About to check if nil.", file: #file, function: #function, line: #line) // NEW DETAILED LOG
axDebugLog("PathNav/EMAC: For element [\(elementDescriptionForLog)], component [\(pathComponentForLog)], attr [\(key)], fetched value is: [\(String(describing: fetchedAttributeValue))]. About to check if nil.", file: #file, function: #function, line: #line) // NEW DETAILED LOG
guard let actualValue = fetchedAttributeValue else {
axDebugLog("Element [\\(elementDescriptionForLog)] lacks attribute [\\(key)] (value was nil after fetch) for path component [\\(pathComponentForLog)]. No match.", file: #file, function: #function, line: #line) // Modified log
axDebugLog("Element [\(elementDescriptionForLog)] lacks attribute [\(key)] (value was nil after fetch) for path component [\(pathComponentForLog)]. No match.", file: #file, function: #function, line: #line) // Modified log
return false
}
if actualValue != expectedValue {
axDebugLog("Element [\\(elementDescriptionForLog)] attribute [\\(key)] value [\\(actualValue)] != expected [\\(expectedValue)] (for path component [\\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] value [\(actualValue)] != expected [\(expectedValue)] (for path component [\(pathComponentForLog)]). No match.", file: #file, function: #function, line: #line)
return false
}
axDebugLog("Element [\\(elementDescriptionForLog)] attribute [\\(key)] value [\\(actualValue)] == expected [\\(expectedValue)] (for path component [\\(pathComponentForLog)]). Criterion met.", file: #file, function: #function, line: #line)
axDebugLog("Element [\(elementDescriptionForLog)] attribute [\(key)] value [\(actualValue)] == expected [\(expectedValue)] (for path component [\(pathComponentForLog)]). Criterion met.", file: #file, function: #function, line: #line)
}
}
axDebugLog("Element [\\(elementDescriptionForLog)] matches ALL criteria for path component [\\(pathComponentForLog)]. Match!", file: #file, function: #function, line: #line)
axDebugLog("Element [\(elementDescriptionForLog)] matches ALL criteria for path component [\(pathComponentForLog)]. Match!", file: #file, function: #function, line: #line)
return true
}
@ -79,8 +79,8 @@ internal func navigateToElement(
if index >= maxDepth {
axDebugLog(
"Navigation aborted: Path hint index \\(index) reached maxDepth \\(maxDepth). " +
"Path so far: \\(currentPathSegmentForLog)",
"Navigation aborted: Path hint index \(index) reached maxDepth \(maxDepth). " +
"Path so far: \(currentPathSegmentForLog)",
file: #file,
function: #function,
line: #line
@ -92,7 +92,7 @@ internal func navigateToElement(
guard !criteriaToMatch.isEmpty else {
axErrorLog(
"CRITICAL_NAV_PARSE_FAILURE_MARKER: Empty or unparsable criteria from " +
"pathComponentString '\\(pathComponentString)'",
"pathComponentString '\(pathComponentString)'",
file: #file,
function: #function,
line: #line
@ -115,7 +115,7 @@ internal func navigateToElement(
}
axDebugLog(
"Navigation successful. Final element: \\(currentElement.briefDescription(option: .default))",
"Navigation successful. Final element: \(currentElement.briefDescription(option: .default))",
file: #file,
function: #function,
line: #line
@ -131,34 +131,54 @@ private func processPathComponent(
criteriaToMatch: [String: String],
currentPathSegmentForLog: String // For logging
) async -> Element? {
let briefDesc = currentElement.briefDescription(option: .default)
logPathComponentProcessing(pathComponentString: pathComponentString, briefDesc: briefDesc)
// DIRECT LOGGING ATTEMPT
await GlobalAXLogger.shared.log(AXLogEntry(level: .debug, message: "PathNav/PPC_DIRECT_LOG: Entered for \(pathComponentString)", file: #file, function: #function, line: Int(#line)))
var stepCounter = 0
axDebugLog("PathNav/PPC: Step \(stepCounter). Before briefDesc.")
stepCounter += 1
let briefDesc = currentElement.briefDescription(option: .default)
axDebugLog("PathNav/PPC: Step \(stepCounter). Before logPathComponentProcessing. BriefDesc: \(briefDesc)")
stepCounter += 1
logPathComponentProcessing(pathComponentString: pathComponentString, briefDesc: briefDesc)
axDebugLog("PathNav/PPC: Step \(stepCounter). After logPathComponentProcessing. Before PRE-CALL FMIC.")
stepCounter += 1
axDebugLog("PathNav/PPC: PRE-CALL FMIC (SIMPLE)", file: #file, function: #function, line: #line)
try? await Task.sleep(nanoseconds: 100_000_000) // Diagnostic delay
axDebugLog("PathNav/PPC: Step \(stepCounter). After PRE-CALL FMIC. Before findMatchingChild call.")
stepCounter += 1
// Priority 1: Check children
if let matchedChild = await findMatchingChild(
currentElement: currentElement,
criteriaToMatch: criteriaToMatch,
pathComponentForLog: pathComponentString // Pass for logging inside elementMatchesAllCriteria
) {
axDebugLog("PathNav/PPC: Step \(stepCounter). findMatchingChild returned non-nil.")
return matchedChild
}
axDebugLog("PathNav/PPC: Step \(stepCounter). findMatchingChild returned nil. Before elementMatchesAllCriteria.")
stepCounter += 1
// Priority 2: If no child matched, check current element itself
if await elementMatchesAllCriteria(currentElement, criteria: criteriaToMatch, forPathComponent: pathComponentString) {
axDebugLog(
"Current element \\(briefDesc) itself matches component '\\(pathComponentString)'. " +
"Current element \(briefDesc) itself matches component '\(pathComponentString)'. " +
"Retaining current element for this step.",
file: #file, function: #function, line: #line
)
axDebugLog("PathNav/PPC: Step \(stepCounter). elementMatchesAllCriteria on currentElement was true.")
return currentElement
}
axDebugLog("PathNav/PPC: Step \(stepCounter). elementMatchesAllCriteria on currentElement was false. Before logNoMatchFound.")
stepCounter += 1
// No match found
logNoMatchFound(
briefDesc: briefDesc,
pathComponentString: pathComponentString,
currentPathSegmentForLog: currentPathSegmentForLog
)
axDebugLog("PathNav/PPC: Step \(stepCounter). After logNoMatchFound. Returning nil.")
return nil
}
@ -166,8 +186,8 @@ private func processPathComponent(
@MainActor
private func logPathComponentProcessing(pathComponentString: String, briefDesc: String) {
axDebugLog(
"Navigating: Processing path component '\\(pathComponentString)' " +
"from current element: \\(briefDesc)",
"Navigating: Processing path component '\(pathComponentString)' " +
"from current element: \(briefDesc)",
file: #file,
function: #function,
line: #line
@ -182,9 +202,9 @@ private func logNoMatchFound(
currentPathSegmentForLog: String
) {
axDebugLog(
"Neither current element \\(briefDesc) nor its children (after all checks) " +
"matched criteria for path component '\\(pathComponentString)'. " +
"Path: \\(currentPathSegmentForLog) // CHILD_MATCH_FAILURE_MARKER",
"Neither current element \(briefDesc) nor its children (after all checks) " +
"matched criteria for path component '\(pathComponentString)'. " +
"Path: \(currentPathSegmentForLog) // CHILD_MATCH_FAILURE_MARKER",
file: #file,
function: #function,
line: #line
@ -198,17 +218,33 @@ private func findMatchingChild(
criteriaToMatch: [String: String],
pathComponentForLog: String // Pass for logging inside elementMatchesAllCriteria
) async -> Element? {
axDebugLog("PathNav/FMIC: ABSOLUTE ENTRY (SIMPLE)", file: #file, function: #function, line: #line)
try? await Task.sleep(nanoseconds: 100_000_000) // Diagnostic delay
axDebugLog("PathNav/FMIC: Entered function for component [\(pathComponentForLog)]. Criteria: \(criteriaToMatch)", file: #file, function: #function, line: #line)
guard let children = await getChildrenFromElement(currentElement) else {
return nil // Logged in getChildrenFromElement if no children
return nil
}
logChildCount(count: children.count) // Optional: log how many children are being checked
if children.isEmpty {
axDebugLog("PathNav/FMIC: Children array IS EMPTY for component [\(pathComponentForLog)]. No children to iterate.", file: #file, function: #function, line: #line)
return nil
}
return await findMatchInChildren(
children: children,
criteriaToMatch: criteriaToMatch,
pathComponentForLog: pathComponentForLog
)
axDebugLog("PathNav/FMIC: Iterating \(children.count) children for component [\(pathComponentForLog)]. Criteria to match: \(criteriaToMatch)", file: #file, function: #function, line: #line)
for (childIndex, child) in children.enumerated() {
let childDescriptionForLog = child.briefDescription(option: .default)
axDebugLog("PathNav/FMIC: Child [\(childIndex)]/[\(children.count - 1)]: [\(childDescriptionForLog)]. About to call EMAC for component [\(pathComponentForLog)].", file: #file, function: #function, line: #line)
// Re-enable this check
if await elementMatchesAllCriteria(child, criteria: criteriaToMatch, forPathComponent: pathComponentForLog) {
axDebugLog("Matched component [\(pathComponentForLog)] to child: [\(childDescriptionForLog)]",
file: #file, function: #function, line: #line)
return child
}
}
axDebugLog("PathNav/FMIC: Loop finished or no match from EMAC. Returning nil.", file: #file, function: #function, line: #line)
return nil
}
// Helper to get children from element
@ -217,7 +253,7 @@ private func getChildrenFromElement(_ element: Element) async -> [Element]? {
guard let children = await element.children() else {
let currentElementDescForLog = element.briefDescription(option: .default)
axDebugLog(
"Current element [\\(currentElementDescForLog)] has no children from Element.children() " +
"Current element [\(currentElementDescForLog)] has no children from Element.children() " +
"or children array was nil.",
file: #file,
function: #function,
@ -232,35 +268,13 @@ private func getChildrenFromElement(_ element: Element) async -> [Element]? {
@MainActor
private func logChildCount(count: Int) {
axDebugLog(
"Child count from Element.children(): \\(count)",
"Child count from Element.children(): \(count)",
file: #file,
function: #function,
line: #line
)
}
// Helper to find a match in children array
@MainActor
private func findMatchInChildren(
children: [Element],
criteriaToMatch: [String: String],
pathComponentForLog: String // Pass for logging inside elementMatchesAllCriteria
) async -> Element? {
axDebugLog("PathNav/FMIC: Iterating \(children.count) children for component [\(pathComponentForLog)]. Criteria to match: \(criteriaToMatch)", file: #file, function: #function, line: #line)
for (childIndex, child) in children.enumerated() {
let childDescriptionForLog = child.briefDescription(option: .default)
axDebugLog("PathNav/FMIC: Child [\(childIndex)]/[\(children.count - 1)]: [\(childDescriptionForLog)]. About to call EMAC for component [\(pathComponentForLog)].", file: #file, function: #function, line: #line)
if await elementMatchesAllCriteria(child, criteria: criteriaToMatch, forPathComponent: pathComponentForLog) {
// Matched child! Log before returning.
axDebugLog("Matched component [\\(pathComponentForLog)] to child: [\\(childDescriptionForLog)]",
file: #file, function: #function, line: #line)
return child
}
}
// No child matched all criteria for this component
return nil
}
// MARK: - Index-based Navigation (If still needed, would need careful review)
// The PathUtils.parseRichPathComponent currently does not produce index-based hints.
// If "@index" style hints are required, parseRichPathComponent and the matching logic
@ -274,10 +288,10 @@ private func findMatchInChildren(
if let indexStr = criteriaToMatch["@index"], let index = Int(indexStr) {
if let children = getChildrenFromElement(currentElement), index >= 0, index < children.count {
let indexedChild = children[index]
axDebugLog("Path component '\\(pathComponentString)' resolved to child at index \\(index): \\(indexedChild.briefDescription())")
axDebugLog("Path component '\(pathComponentString)' resolved to child at index \(index): \(indexedChild.briefDescription())")
return indexedChild
} else {
axDebugLog("Path component '\\(pathComponentString)' (index \\(index)) out of bounds for \\(currentElement.briefDescription()) with \\(getChildrenFromElement(currentElement)?.count ?? 0) children.")
axDebugLog("Path component '\(pathComponentString)' (index \(index)) out of bounds for \(currentElement.briefDescription()) with \(getChildrenFromElement(currentElement)?.count ?? 0) children.")
// 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
@ -349,7 +363,7 @@ private func original_logChildCheck( // Marked as original
) {
let matchStatus = (actualValue == expectedValue) ? "==" : "!="
axDebugLog(
"Checking child: \\(childDesc) | Attribute: \\(attributeName) | Actual: '\\(actualValue)' \\(matchStatus) Expected: '\\(expectedValue)'",
"Checking child: \(childDesc) | Attribute: \(attributeName) | Actual: '\(actualValue)' \(matchStatus) Expected: '\(expectedValue)'",
file: #file,
function: #function,
line: #line
@ -363,7 +377,7 @@ private func original_logChildMatch( // Marked as original
expectedValue: String
) {
axDebugLog(
"MATCHED child: \\(childDesc) for \\(attributeName):\\(expectedValue)",
"MATCHED child: \(childDesc) for \(attributeName):\(expectedValue)",
file: #file,
function: #function,
line: #line

View File

@ -0,0 +1,68 @@
import Foundation
import CoreGraphics // For CGRect, CGPoint, CGSize
import ApplicationServices // For AXUIElement, AXNotification (if used directly)
// Consider if AXNotification needs to be accessible here, or if its rawValue is sufficient.
// If AXorcist/Models/DataModels.swift defines AXNotification, that might be a better import.
// Recursively sanitize value into JSON-encodable form
internal func sanitizeValue(_ val: Any) -> Any {
switch val {
case let notif as AXNotification: // Assuming AXNotification is accessible
return notif.rawValue
case is AXUIElement:
return "<AXUIElement>" // Placeholder for opaque AXUIElementRef
case let elem as Element: // Assuming Element is accessible
return String(describing: elem) // Or a more specific brief description if safe
case let attrStr as NSAttributedString:
return attrStr.string
case let rect as CGRect:
return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.size.width, "height": rect.size.height]
case let point as CGPoint:
return ["x": point.x, "y": point.y]
case let size as CGSize:
return ["width": size.width, "height": size.height]
case let dict as [String: Any]:
var newDict: [String: Any] = [:]
for (k, v) in dict { newDict[k] = sanitizeValue(v) }
return newDict
case let arr as [Any]:
return arr.map { sanitizeValue($0) }
// Consider adding cases for other common non-JSON-friendly types like URL, Date etc.
// For Date, you might convert to ISO8601 string or epoch timestamp.
default:
// If it's a simple value type (Int, Double, Bool, String), it's already fine.
// For anything else, converting to String is a safe fallback.
// However, be mindful that this might not be the desired representation.
if val is String || val is Int || val is Double || val is Bool || val is NSNull {
return val
}
// Fallback for unknown complex types
return String(describing: val)
}
}
// Ensure all nested values are JSON-serialisable (NSString/NSNumber/NSNull/Array/Dict)
// This function is crucial for preparing the payload for JSONSerialization.
internal func makeJSONCompatible(_ value: Any) -> Any {
switch value {
case let str as String:
return str // Already a JSON primitive
case let num as NSNumber: // Handles Int, Double, Bool bridged from Objective-C
return num // Already a JSON primitive (or convertible)
case is NSNull:
return value // Already a JSON primitive
case let dict as [String: Any]:
var newDict = [String: Any]()
for (k,v) in dict { newDict[k] = makeJSONCompatible(v) }
return newDict // Recurse for dictionary values
case let arr as [Any]:
return arr.map { makeJSONCompatible($0) } // Recurse for array elements
default:
// If it's not one of the above, it's likely not directly JSON serializable.
// Convert to a string representation as a fallback.
// This ensures that JSONSerialization.data(withJSONObject:) doesn't throw an error
// due to an invalid top-level or nested type.
return String(describing: value)
}
}

View File

@ -3,6 +3,7 @@
import ArgumentParser
import AXorcist // For AXorcist instance
import Foundation
import CoreFoundation
@main
struct AXORCCommand: AsyncParsableCommand {
@ -28,6 +29,67 @@ struct AXORCCommand: AsyncParsableCommand {
)
var directPayload: String?
// Helper function to process and execute a CommandEnvelope
private func processAndExecuteCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) async {
if debugCLI {
axDebugLog("Successfully parsed command: \(command.command) (ID: \(command.commandId))")
}
let resultJsonString = await CommandExecutor.execute(
command: command,
axorcist: axorcist,
debugCLI: debugCLI
)
print(resultJsonString)
fflush(stdout)
if command.command == .observe {
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 {
axInfoLog("AXORCMain: Parsed initial response for observe: success=\(success), status=\(status)")
if success && status == "observer_started" {
observerSetupSucceeded = true
axInfoLog("AXORCMain: Observer setup deemed SUCCEEDED for observe command.")
} else {
axInfoLog("AXORCMain: Observer setup deemed FAILED for observe command (success=\(success), status=\(status)).")
}
} else {
axErrorLog("AXORCMain: Failed to parse expected fields (success, status) from observe setup JSON.")
}
} catch {
axErrorLog("AXORCMain: Could not parse result JSON from observe setup to check for success: \(error.localizedDescription)")
}
} else {
axErrorLog("AXORCMain: Could not convert result JSON string to data for observe setup check.")
}
if observerSetupSucceeded {
axInfoLog("AXORCMain: Observer setup successful. Process will remain alive.")
#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 }
}
#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)
fflush(stderr)
exit(1)
#endif
} else {
axErrorLog("AXORCMain: Observe command setup reported failure or result was not a success status. Exiting.")
}
} else {
await axClearLogs() // Clear logs for non-observe commands after execution
}
}
mutating func run() async throws {
// Configure GlobalAXLogger based on debug flag
await GlobalAXLogger.shared.setLoggingEnabled(debug)
@ -60,7 +122,7 @@ struct AXORCCommand: AsyncParsableCommand {
return
}
guard let jsonString = inputResult.jsonString else {
guard var jsonStringFromInput = inputResult.jsonString else {
let collectedLogs = debug ? await GlobalAXLogger.shared.getLogsAsStrings(format: .text, includeTimestamps: true, includeLevels: true, includeDetails: true) : nil
let errorResponse = ErrorResponse(
@ -77,9 +139,37 @@ struct AXORCCommand: AsyncParsableCommand {
}
return
}
axDebugLog("AXORCMain: jsonStringFromInput (from InputHandler): [\(jsonStringFromInput)] (length: \(jsonStringFromInput.count))")
// Ensure we are working with a "concrete" String instance to avoid Substring/StringProtocol ambiguities
var jsonString = String(jsonStringFromInput)
axDebugLog("AXORCMain: jsonString (after String(jsonStringFromInput)): [\(jsonString)] (length: \(jsonString.count))")
// Log first/last chars of the concrete jsonString
if !jsonString.isEmpty {
axDebugLog("AXORCMain: First char of concrete jsonString: \(jsonString.first!) (ASCII: \(jsonString.first!.asciiValue ?? 0)), Last char: \(jsonString.last!) (ASCII: \(jsonString.last!.asciiValue ?? 0))")
}
// Parse JSON command
guard let jsonData = jsonString.data(using: .utf8) else {
var dataToDecode = jsonString.data(using: .utf8) // Default to using the concrete jsonString
var didAttemptUnwrap = false
if jsonString.hasPrefix("[") && jsonString.hasSuffix("]") && jsonString.count > 2 { // Use concrete jsonString for checks
let innerContentString = String(jsonString.dropFirst().dropLast())
axDebugLog("AXORCMain: Original concrete jsonString appeared to be an array. Attempting to use its inner content: [\(innerContentString)]")
if let innerData = innerContentString.data(using: .utf8) {
dataToDecode = innerData
didAttemptUnwrap = true
} else {
axDebugLog("AXORCMain: Failed to convert innerContentString to data. Will use original concrete jsonString data.")
}
} else {
axDebugLog("AXORCMain: Original concrete jsonString does not appear to be a simple array wrapper. Proceeding with it for data conversion.")
}
// axDebugLog("AXORCMain: effectiveJsonString after unwrap attempt: [\(effectiveJsonString)]") // Old log, dataToDecode is now key
guard let jsonData = dataToDecode else {
// Clear logs after error
await axClearLogs()
print("{\"error\": \"Failed to convert JSON string to data\"}")
@ -87,61 +177,42 @@ struct AXORCCommand: AsyncParsableCommand {
}
if debug {
axDebugLog("AXORCMain: jsonString before decode: [\(jsonString)]")
axDebugLog("AXORCMain: jsonData.count before decode: \(jsonData.count)")
axDebugLog("AXORCMain: jsonData.count before decode (this is from effective/unwrapped data if unwrap occurred): \(jsonData.count)")
}
let axorcist = AXorcist() // Initialize once, outside the do-catch for broader scope
do {
// This is the primary attempt, using `jsonData` (derived from `dataToDecode`,
// which is from `jsonString` after faulty unwrap attempt due to string issues)
let command = try JSONDecoder().decode(CommandEnvelope.self, from: jsonData)
if debug {
axDebugLog("Successfully parsed command: \(command.command)")
}
// Execute command using CommandExecutor
let axorcist = AXorcist()
let result = await CommandExecutor.execute(
command: command,
axorcist: axorcist,
debugCLI: debug
)
print(result) // CommandExecutor.execute should return a string (JSON response)
// Stop collecting logs after successful execution
// Clear logs after error
await axClearLogs()
} catch {
axErrorLog("DECODE_ERROR_DEBUG: Original jsonString that led to this error: [\(jsonString)]")
axErrorLog("DECODE_ERROR_DEBUG: jsonData.count that led to this error: \(jsonData.count)")
axErrorLog("DECODE_ERROR_DEBUG: Raw error.localizedDescription: \(error.localizedDescription)")
axErrorLog("DECODE_ERROR_DEBUG: Full error object: \(error)")
let errorMessage = "Failed to parse JSON command. Raw Error: \(error.localizedDescription). JSON Input (first 100 chars): \(jsonString.prefix(100))..."
// For decode errors, always collect logs
if !debug {
await GlobalAXLogger.shared.setLoggingEnabled(true)
await GlobalAXLogger.shared.setDetailLevel(.verbose)
}
let collectedLogs = await GlobalAXLogger.shared.getLogsAsStrings(format: .text, includeTimestamps: true, includeLevels: true, includeDetails: true)
await axClearLogs()
let errorResponse = ErrorResponse(
commandId: "decode_error",
error: errorMessage,
debugLogs: collectedLogs
)
if let responseData = try? JSONEncoder().encode(errorResponse),
let responseStr = String(data: responseData, encoding: .utf8) {
print(responseStr)
axDebugLog("AXORCMain: Decode attempt 1 (from jsonData derived from potentially pre-unwrapped jsonString) successful.")
await processAndExecuteCommand(command: command, axorcist: axorcist, debugCLI: debug)
} catch let error1 {
axDebugLog("AXORCMain: Decode attempt 1 (from jsonData) FAILED. Error: \(error1). jsonStringFromInput (raw from InputHandler) was: [\(jsonStringFromInput)]")
// Fallback: Assume jsonStringFromInput is "[{...}]" because InputHandler (via ArgumentParser) seems to yield this.
// Try to extract "{...}" and decode that as a single CommandEnvelope.
if jsonStringFromInput.count > 2 { // Basic check for "[]" at least
let potentiallyInnerJsonString = String(jsonStringFromInput.dropFirst().dropLast())
axDebugLog("AXORCMain: Fallback: Extracted potentiallyInnerJsonString from jsonStringFromInput: [\(potentiallyInnerJsonString)]")
if let innerData = potentiallyInnerJsonString.data(using: .utf8) {
do {
let command = try JSONDecoder().decode(CommandEnvelope.self, from: innerData)
axDebugLog("AXORCMain: Decode attempt 2 (from inner content of jsonStringFromInput) SUCCESSFUL.")
await processAndExecuteCommand(command: command, axorcist: axorcist, debugCLI: debug)
} catch let error2 {
axDebugLog("AXORCMain: Decode attempt 2 (from inner content of jsonStringFromInput) FAILED. Error: \(error2). Will rethrow original error from attempt 1.")
throw error1 // Rethrow original error from attempt 1
}
} else {
axDebugLog("AXORCMain: Fallback: Failed to convert potentiallyInnerJsonString to data. Will rethrow original error from attempt 1.")
throw error1 // Rethrow original error from attempt 1
}
} else {
// Fallback if even error encoding fails
let fallbackErrorMsg = "{\"error\": \"Failed to encode error response. Original error for decode: \(error.localizedDescription). Input was: \(jsonString)\"}"
print(fallbackErrorMsg)
axDebugLog("AXORCMain: Fallback: jsonStringFromInput too short to be '[{...}]'. Will rethrow original error from attempt 1.")
throw error1 // Rethrow original error from attempt 1
}
}
// Removed the final generic catch to ensure errors propagate to ArgumentParser if not handled by the specific fallback.
}
}

View File

@ -113,6 +113,10 @@ struct CommandExecutor {
case .getElementAtPoint:
// Pass debugCLI to handler
return await handleNotImplementedCommand(command: command, message: "getElementAtPoint command not yet implemented", debugCLI: debugCLI)
case .observe:
// Pass debugCLI to handler
return await handleObserveCommand(command: command, axorcist: axorcist, debugCLI: debugCLI)
}
}
@ -445,6 +449,81 @@ struct CommandExecutor {
return nil
}
}
// Placeholder for handleObserveCommand
private static func handleObserveCommand(command: CommandEnvelope, axorcist: AXorcist, debugCLI: Bool) async -> String {
axDebugLog("Observe command received by CommandExecutor. debugCLI: \(debugCLI)")
guard let notifications = command.notifications, !notifications.isEmpty else {
let errorMsg = "Missing or empty 'notifications' array for observe command."
axErrorLog(errorMsg)
return await finalizeAndEncodeResponse(
commandId: command.commandId,
commandType: command.command.rawValue,
handlerResponse: HandlerResponse(data: nil, error: errorMsg),
debugCLI: debugCLI
)
}
let includeDetails = command.includeElementDetails ?? []
let watchChildren = command.watchChildren ?? false
let observerSetupSuccess = await axorcist.handleObserve(
for: command.application,
notifications: notifications,
includeElementDetails: includeDetails,
watchChildren: watchChildren,
commandId: command.commandId,
debugCLI: debugCLI
)
if observerSetupSuccess {
// Observer started successfully. Print initial success message to stdout.
// Further notification data will be streamed directly to stdout by AXorcist.handleObserve's callback.
let successResponsePayload: [String: AnyCodable] = [
"commandId": AnyCodable(command.commandId),
"command": AnyCodable(command.command.rawValue),
"status": AnyCodable("observer_started"),
"success": AnyCodable(true) // Indicate successful setup
]
// let logs = await GlobalAXLogger.shared.getLogsAsStringsIfEnabled(format: .text)
// No logs are added to this initial success message for observe,
// as the primary output is the stream of notifications.
do {
let jsonData = try JSONEncoder().encode(successResponsePayload)
if let jsonString = String(data: jsonData, encoding: .utf8) {
// This will be the only JSON output from CommandExecutor for a successful observe setup.
// AXORCMain will need to ensure the process stays alive.
return jsonString
} else {
let errorMsg = "{\"error\": \"Failed to encode initial success response for observe command.\"}"
fputs("\(errorMsg)\n", stderr)
fflush(stderr)
return errorMsg // Return error string
}
} catch {
let errorMsg = "{\"error\": \"Exception encoding initial success response: \(error.localizedDescription)\"}"
fputs("\(errorMsg)\n", stderr)
fflush(stderr)
return errorMsg // Return error string
}
// DO NOT CALL RunLoop.current.run() here.
// AXORCMain will handle keeping the process alive.
} else {
// Failed to start observer
let errorMsg = "Failed to start observer for application: \(command.application ?? "focused")"
axErrorLog(errorMsg)
return await finalizeAndEncodeResponse(
commandId: command.commandId,
commandType: command.command.rawValue,
handlerResponse: HandlerResponse(data: nil, error: errorMsg),
debugCLI: debugCLI
)
}
}
}
// Extension to GlobalAXLogger for convenience

View File

@ -219,7 +219,7 @@ func testQueryForTextEditTextArea() async throws {
"Debug logs should indicate query execution."
)
}
}
@Test("Describe TextEdit Text Area")
@MainActor
@ -293,7 +293,7 @@ func testDescribeTextEditTextArea() async throws {
"Debug logs should indicate describeElement execution."
)
}
}
// MARK: - Helper Functions