diff --git a/AXspector/AXspector.xcodeproj/project.pbxproj b/AXspector/AXspector.xcodeproj/project.pbxproj deleted file mode 100644 index 85b0286..0000000 --- a/AXspector/AXspector.xcodeproj/project.pbxproj +++ /dev/null @@ -1,556 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXContainerItemProxy section */ - 785C57082DDD38FF00BB9827 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 785C56F12DDD38FD00BB9827 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 785C56F82DDD38FD00BB9827; - remoteInfo = AXspector; - }; - 785C57122DDD38FF00BB9827 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 785C56F12DDD38FD00BB9827 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 785C56F82DDD38FD00BB9827; - remoteInfo = AXspector; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 785C56F92DDD38FD00BB9827 /* AXspector.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AXspector.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 785C57072DDD38FF00BB9827 /* AXspectorTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AXspectorTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 785C57112DDD38FF00BB9827 /* AXspectorUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AXspectorUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 785C56FB2DDD38FD00BB9827 /* AXspector */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = AXspector; - sourceTree = ""; - }; - 785C570A2DDD38FF00BB9827 /* AXspectorTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = AXspectorTests; - sourceTree = ""; - }; - 785C57142DDD38FF00BB9827 /* AXspectorUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = AXspectorUITests; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 785C56F62DDD38FD00BB9827 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 785C57042DDD38FF00BB9827 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 785C570E2DDD38FF00BB9827 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 785C56F02DDD38FD00BB9827 = { - isa = PBXGroup; - children = ( - 785C56FB2DDD38FD00BB9827 /* AXspector */, - 785C570A2DDD38FF00BB9827 /* AXspectorTests */, - 785C57142DDD38FF00BB9827 /* AXspectorUITests */, - 785C56FA2DDD38FD00BB9827 /* Products */, - ); - sourceTree = ""; - }; - 785C56FA2DDD38FD00BB9827 /* Products */ = { - isa = PBXGroup; - children = ( - 785C56F92DDD38FD00BB9827 /* AXspector.app */, - 785C57072DDD38FF00BB9827 /* AXspectorTests.xctest */, - 785C57112DDD38FF00BB9827 /* AXspectorUITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 785C56F82DDD38FD00BB9827 /* AXspector */ = { - isa = PBXNativeTarget; - buildConfigurationList = 785C571B2DDD38FF00BB9827 /* Build configuration list for PBXNativeTarget "AXspector" */; - buildPhases = ( - 785C56F52DDD38FD00BB9827 /* Sources */, - 785C56F62DDD38FD00BB9827 /* Frameworks */, - 785C56F72DDD38FD00BB9827 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 785C56FB2DDD38FD00BB9827 /* AXspector */, - ); - name = AXspector; - packageProductDependencies = ( - ); - productName = AXspector; - productReference = 785C56F92DDD38FD00BB9827 /* AXspector.app */; - productType = "com.apple.product-type.application"; - }; - 785C57062DDD38FF00BB9827 /* AXspectorTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 785C571E2DDD38FF00BB9827 /* Build configuration list for PBXNativeTarget "AXspectorTests" */; - buildPhases = ( - 785C57032DDD38FF00BB9827 /* Sources */, - 785C57042DDD38FF00BB9827 /* Frameworks */, - 785C57052DDD38FF00BB9827 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 785C57092DDD38FF00BB9827 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 785C570A2DDD38FF00BB9827 /* AXspectorTests */, - ); - name = AXspectorTests; - packageProductDependencies = ( - ); - productName = AXspectorTests; - productReference = 785C57072DDD38FF00BB9827 /* AXspectorTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 785C57102DDD38FF00BB9827 /* AXspectorUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 785C57212DDD38FF00BB9827 /* Build configuration list for PBXNativeTarget "AXspectorUITests" */; - buildPhases = ( - 785C570D2DDD38FF00BB9827 /* Sources */, - 785C570E2DDD38FF00BB9827 /* Frameworks */, - 785C570F2DDD38FF00BB9827 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 785C57132DDD38FF00BB9827 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 785C57142DDD38FF00BB9827 /* AXspectorUITests */, - ); - name = AXspectorUITests; - packageProductDependencies = ( - ); - productName = AXspectorUITests; - productReference = 785C57112DDD38FF00BB9827 /* AXspectorUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 785C56F12DDD38FD00BB9827 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 1640; - TargetAttributes = { - 785C56F82DDD38FD00BB9827 = { - CreatedOnToolsVersion = 16.4; - }; - 785C57062DDD38FF00BB9827 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = 785C56F82DDD38FD00BB9827; - }; - 785C57102DDD38FF00BB9827 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = 785C56F82DDD38FD00BB9827; - }; - }; - }; - buildConfigurationList = 785C56F42DDD38FD00BB9827 /* Build configuration list for PBXProject "AXspector" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 785C56F02DDD38FD00BB9827; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = 785C56FA2DDD38FD00BB9827 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 785C56F82DDD38FD00BB9827 /* AXspector */, - 785C57062DDD38FF00BB9827 /* AXspectorTests */, - 785C57102DDD38FF00BB9827 /* AXspectorUITests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 785C56F72DDD38FD00BB9827 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 785C57052DDD38FF00BB9827 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 785C570F2DDD38FF00BB9827 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 785C56F52DDD38FD00BB9827 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 785C57032DDD38FF00BB9827 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 785C570D2DDD38FF00BB9827 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 785C57092DDD38FF00BB9827 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 785C56F82DDD38FD00BB9827 /* AXspector */; - targetProxy = 785C57082DDD38FF00BB9827 /* PBXContainerItemProxy */; - }; - 785C57132DDD38FF00BB9827 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 785C56F82DDD38FD00BB9827 /* AXspector */; - targetProxy = 785C57122DDD38FF00BB9827 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 785C57192DDD38FF00BB9827 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = Y5PE65HELJ; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 785C571A2DDD38FF00BB9827 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = Y5PE65HELJ; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - }; - name = Release; - }; - 785C571C2DDD38FF00BB9827 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = AXspector/AXspector.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Y5PE65HELJ; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = me.steipete.AXspector; - PRODUCT_NAME = "$(TARGET_NAME)"; - REGISTER_APP_GROUPS = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 785C571D2DDD38FF00BB9827 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = AXspector/AXspector.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Y5PE65HELJ; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = me.steipete.AXspector; - PRODUCT_NAME = "$(TARGET_NAME)"; - REGISTER_APP_GROUPS = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 785C571F2DDD38FF00BB9827 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Y5PE65HELJ; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = me.steipete.AXspectorTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AXspector.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AXspector"; - }; - name = Debug; - }; - 785C57202DDD38FF00BB9827 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Y5PE65HELJ; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = me.steipete.AXspectorTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AXspector.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AXspector"; - }; - name = Release; - }; - 785C57222DDD38FF00BB9827 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Y5PE65HELJ; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = me.steipete.AXspectorUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = AXspector; - }; - name = Debug; - }; - 785C57232DDD38FF00BB9827 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Y5PE65HELJ; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = me.steipete.AXspectorUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = AXspector; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 785C56F42DDD38FD00BB9827 /* Build configuration list for PBXProject "AXspector" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 785C57192DDD38FF00BB9827 /* Debug */, - 785C571A2DDD38FF00BB9827 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 785C571B2DDD38FF00BB9827 /* Build configuration list for PBXNativeTarget "AXspector" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 785C571C2DDD38FF00BB9827 /* Debug */, - 785C571D2DDD38FF00BB9827 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 785C571E2DDD38FF00BB9827 /* Build configuration list for PBXNativeTarget "AXspectorTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 785C571F2DDD38FF00BB9827 /* Debug */, - 785C57202DDD38FF00BB9827 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 785C57212DDD38FF00BB9827 /* Build configuration list for PBXNativeTarget "AXspectorUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 785C57222DDD38FF00BB9827 /* Debug */, - 785C57232DDD38FF00BB9827 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 785C56F12DDD38FD00BB9827 /* Project object */; -} diff --git a/AXspector/AXspector.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/AXspector/AXspector.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/AXspector/AXspector.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/AXspector/AXspector/AXspector.entitlements b/AXspector/AXspector/AXspector.entitlements deleted file mode 100755 index 18aff0c..0000000 --- a/AXspector/AXspector/AXspector.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - - diff --git a/AXspector/AXspector/AXspectorApp.swift b/AXspector/AXspector/AXspectorApp.swift deleted file mode 100755 index a0a07a2..0000000 --- a/AXspector/AXspector/AXspectorApp.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// AXspectorApp.swift -// AXspector -// -// Created by Peter Steinberger on 21.05.25. -// - -import SwiftUI - -@main -struct AXspectorApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/AXspector/AXspector/Assets.xcassets/AccentColor.colorset/Contents.json b/AXspector/AXspector/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100755 index eb87897..0000000 --- a/AXspector/AXspector/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/AXspector/AXspector/Assets.xcassets/AppIcon.appiconset/Contents.json b/AXspector/AXspector/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 3f00db4..0000000 --- a/AXspector/AXspector/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "images" : [ - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/AXspector/AXspector/Assets.xcassets/Contents.json b/AXspector/AXspector/Assets.xcassets/Contents.json deleted file mode 100755 index 73c0059..0000000 --- a/AXspector/AXspector/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/AXspector/AXspector/ContentView.swift b/AXspector/AXspector/ContentView.swift deleted file mode 100755 index fd95d63..0000000 --- a/AXspector/AXspector/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// AXspector -// -// Created by Peter Steinberger on 21.05.25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/AXspector/AXspectorTests/AXspectorTests.swift b/AXspector/AXspectorTests/AXspectorTests.swift deleted file mode 100755 index 8bed4cb..0000000 --- a/AXspector/AXspectorTests/AXspectorTests.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// AXspectorTests.swift -// AXspectorTests -// -// Created by Peter Steinberger on 21.05.25. -// - -import Testing -@testable import AXspector - -struct AXspectorTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } - -} diff --git a/AXspector/AXspectorUITests/AXspectorUITests.swift b/AXspector/AXspectorUITests/AXspectorUITests.swift deleted file mode 100755 index 32a7dbe..0000000 --- a/AXspector/AXspectorUITests/AXspectorUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// AXspectorUITests.swift -// AXspectorUITests -// -// Created by Peter Steinberger on 21.05.25. -// - -import XCTest - -final class AXspectorUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } -} diff --git a/AXspector/AXspectorUITests/AXspectorUITestsLaunchTests.swift b/AXspector/AXspectorUITests/AXspectorUITestsLaunchTests.swift deleted file mode 100755 index 590c1e5..0000000 --- a/AXspector/AXspectorUITests/AXspectorUITestsLaunchTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AXspectorUITestsLaunchTests.swift -// AXspectorUITests -// -// Created by Peter Steinberger on 21.05.25. -// - -import XCTest - -final class AXspectorUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - @MainActor - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..76449fa --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +# Makefile for axorc helper + +# Define the output binary name +BINARY_NAME = axorc +UNIVERSAL_BINARY_PATH = ./$(BINARY_NAME) +RELEASE_BUILD_DIR := ./.build/arm64-apple-macosx/release +RELEASE_BUILD_DIR_X86 := ./.build/x86_64-apple-macosx/release + +# Build for arm64 and x86_64, then lipo them together +# -Xswiftc -Osize: Optimize for size +# -Xlinker -Wl,-dead_strip: Remove dead code +# strip -x: Strip symbol table and debug info +# Ensure old binary is removed first +all: + @echo "Cleaning old binary and build artifacts..." + rm -f $(UNIVERSAL_BINARY_PATH) + swift package clean + @echo "Building for arm64..." + swift build --arch arm64 -c release -Xswiftc -Osize -Xlinker -dead_strip + @echo "Building for x86_64..." + swift build --arch x86_64 -c release -Xswiftc -Osize -Xlinker -dead_strip + @echo "Creating universal binary..." + lipo -create -output $(UNIVERSAL_BINARY_PATH) $(RELEASE_BUILD_DIR)/$(BINARY_NAME) $(RELEASE_BUILD_DIR_X86)/$(BINARY_NAME) + @echo "Stripping symbols from universal binary..." + strip -x $(UNIVERSAL_BINARY_PATH) + @echo "Build complete: $(UNIVERSAL_BINARY_PATH)" + @ls -l $(UNIVERSAL_BINARY_PATH) + @codesign -s - $(UNIVERSAL_BINARY_PATH) + @echo "Codesigned $(UNIVERSAL_BINARY_PATH)" + + +clean: + @echo "Cleaning build artifacts..." + swift package clean + rm -f $(UNIVERSAL_BINARY_PATH) + @echo "Clean complete." + +# Default target +.DEFAULT_GOAL := all diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0fb601d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,32 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-testing.git", + "state" : { + "revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211", + "version" : "0.99.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..413accf --- /dev/null +++ b/Package.swift @@ -0,0 +1,44 @@ +// swift-tools-version:5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "axPackage", // Renamed package slightly to avoid any confusion with executable name + platforms: [ + .macOS(.v13) // macOS 13.0 or later + ], + products: [ + .library(name: "AXorcist", targets: ["AXorcist"]), + .executable(name: "axorc", targets: ["axorc"]) // Product 'axorc' comes from target 'axorc' + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), // Added swift-argument-parser + .package(url: "https://github.com/apple/swift-testing.git", from: "0.6.0") // Added swift-testing + ], + targets: [ + .target( + name: "AXorcist", // New library target name + path: "Sources/AXorcist" // Explicit path + // Sources will be inferred by SPM + ), + .executableTarget( + name: "axorc", // Executable target name + dependencies: [ + "AXorcist", + .product(name: "ArgumentParser", package: "swift-argument-parser") // Added dependency product + ], + path: "Sources/axorc" // Explicit path + // Sources (axorc.swift) will be inferred by SPM + ), + .testTarget( + name: "AXorcistTests", + dependencies: [ + "AXorcist", // Test target depends on the library + .product(name: "Testing", package: "swift-testing") // Added swift-testing dependency + ], + path: "Tests/AXorcistTests" // Explicit path + // Sources will be inferred by SPM + ) + ] +) \ No newline at end of file diff --git a/Sources/AXorcist/AXorcist.swift b/Sources/AXorcist/AXorcist.swift new file mode 100644 index 0000000..149d7b3 --- /dev/null +++ b/Sources/AXorcist/AXorcist.swift @@ -0,0 +1,960 @@ +import Foundation +import ApplicationServices +import AppKit + +// 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 struct HandlerResponse { + public var data: AXElement? + public var error: String? + public var debug_logs: [String]? + + public init(data: AXElement? = nil, error: String? = nil, debug_logs: [String]? = nil) { + self.data = data + self.error = error + self.debug_logs = debug_logs + } +} + +public class AXorcist { + + private let focusedAppKeyValue = "focused" + private var recursiveCallDebugLogs: [String] = [] // Added for recursive logging + + public init() { + // Future initialization logic can go here. + // For now, ensure debug logs can be collected if needed. + // Note: The actual logging enable/disable should be managed per-call. + // This init doesn't take global logging flags anymore. + } + + // 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, + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] + ) -> HandlerResponse { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + + let appIdentifier = appIdentifierOrNil ?? focusedAppKeyValue + dLog("[AXorcist.handleGetFocusedElement] Handling for app: \(appIdentifier)") + + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMsgText = "Application not found: \(appIdentifier)" + dLog("[AXorcist.handleGetFocusedElement] \(errorMsgText)") + return HandlerResponse(data: nil, error: errorMsgText, debug_logs: currentDebugLogs) + } + dLog("[AXorcist.handleGetFocusedElement] Successfully obtained application element for \(appIdentifier)") + + var cfValue: CFTypeRef? + let copyAttributeStatus = AXUIElementCopyAttributeValue(appElement.underlyingElement, kAXFocusedUIElementAttribute as CFString, &cfValue) + + guard copyAttributeStatus == .success, let rawAXElement = cfValue else { + dLog("[AXorcist.handleGetFocusedElement] Failed to copy focused element attribute or it was nil. Status: \(axErrorToString(copyAttributeStatus)). Application: \(appIdentifier)") + 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))", debug_logs: currentDebugLogs) + } + + guard CFGetTypeID(rawAXElement) == AXUIElementGetTypeID() else { + dLog("[AXorcist.handleGetFocusedElement] Focused element attribute was not an AXUIElement. Application: \(appIdentifier)") + return HandlerResponse(data: nil, error: "Focused element was not a valid UI element for \(appIdentifier).", debug_logs: currentDebugLogs) + } + + let focusedElement = Element(rawAXElement as! AXUIElement) + dLog("[AXorcist.handleGetFocusedElement] Successfully obtained focused element: \(focusedElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs)) for application \(appIdentifier)") + + let fetchedAttributes = getElementAttributes( + focusedElement, + requestedAttributes: requestedAttributes ?? [], + forMultiDefault: false, + targetRole: nil, + outputFormat: .smart, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + let elementPathArray = focusedElement.generatePathArray(upTo: appElement, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + + let axElement = AXElement(attributes: fetchedAttributes, path: elementPathArray) + + return HandlerResponse(data: axElement, error: nil, debug_logs: currentDebugLogs) + } + + // Handle getting attributes for a specific element using locator + @MainActor + public func handleGetAttributes( + for appIdentifierOrNil: String? = nil, + locator: Locator, + requestedAttributes: [String]? = nil, + pathHint: [String]? = nil, + maxDepth: Int? = nil, + outputFormat: OutputFormat? = nil, + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] + ) -> HandlerResponse { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + + let appIdentifier = appIdentifierOrNil ?? focusedAppKeyValue + dLog("[AXorcist.handleGetAttributes] Handling for app: \(appIdentifier)") + + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMessage = "Application not found: \(appIdentifier)" + dLog("[AXorcist.handleGetAttributes] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + + // Find element to get attributes from + var effectiveElement = appElement + if let pathHint = pathHint, !pathHint.isEmpty { + let pathHintString = pathHint.joined(separator: " -> ") + _ = pathHintString // Silences compiler warning + let logMessage = "[AXorcist.handleGetAttributes] Navigating with path_hint: \(pathHintString)" + dLog(logMessage) + if let navigatedElement = navigateToElement(from: effectiveElement, pathHint: pathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + effectiveElement = navigatedElement + } else { + let pathHintStringForError = pathHint.joined(separator: " -> ") + _ = pathHintStringForError // Silences compiler warning + let errorMessageText = "Element not found via path hint: \(pathHintStringForError)" + dLog("[AXorcist.handleGetAttributes] \(errorMessageText)") + return HandlerResponse(data: nil, error: errorMessageText, debug_logs: currentDebugLogs) + } + } + + let rootElementDescription = effectiveElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + _ = rootElementDescription // Silences compiler warning + let searchLogMessage = "[AXorcist.handleGetAttributes] Searching for element with locator: \(locator.criteria) from root: \(rootElementDescription)" + dLog(searchLogMessage) + let foundElement = search( + element: effectiveElement, + locator: locator, + requireAction: locator.requireAction, + maxDepth: maxDepth ?? DEFAULT_MAX_DEPTH_SEARCH, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + if let elementToQuery = foundElement { + let elementDescription = elementToQuery.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + _ = elementDescription // Silences compiler warning + let attributesDescription = (requestedAttributes ?? ["all"]).description + _ = attributesDescription // Silences compiler warning + let foundElementLogMessage = "[AXorcist.handleGetAttributes] Element found: \(elementDescription). Fetching attributes: \(attributesDescription)..." + dLog(foundElementLogMessage) + var attributes = getElementAttributes( + elementToQuery, + requestedAttributes: requestedAttributes ?? [], + forMultiDefault: false, + targetRole: locator.criteria[kAXRoleAttribute], + outputFormat: outputFormat ?? .smart, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + if outputFormat == .json_string { + attributes = encodeAttributesToJSONStringRepresentation(attributes) + } + + let elementPathArray = elementToQuery.generatePathArray(upTo: appElement, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + let axElement = AXElement(attributes: attributes, path: elementPathArray) + + dLog("[AXorcist.handleGetAttributes] Successfully fetched attributes for element \(elementToQuery.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs)).") + return HandlerResponse(data: axElement, error: nil, debug_logs: currentDebugLogs) + } else { + let errorMessage = "No element found for get_attributes with locator: \(String(describing: locator))" + dLog("[AXorcist.handleGetAttributes] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + } + + // Handle query command - find an element matching criteria + @MainActor + public func handleQuery( + for appIdentifierOrNil: String? = nil, + locator: Locator, + pathHint: [String]? = nil, + maxDepth: Int? = nil, + requestedAttributes: [String]? = nil, + outputFormat: OutputFormat? = nil, + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] + ) -> HandlerResponse { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + + let appIdentifier = appIdentifierOrNil ?? focusedAppKeyValue + dLog("[AXorcist.handleQuery] Handling query for app: \(appIdentifier)") + + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMessage = "Application not found: \(appIdentifier)" + dLog("[AXorcist.handleQuery] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + + var effectiveElement = appElement + if let pathHint = pathHint, !pathHint.isEmpty { + let pathHintString = pathHint.joined(separator: " -> ") + _ = pathHintString // Silences compiler warning + dLog("[AXorcist.handleQuery] Navigating with path_hint: \(pathHintString)") + if let navigatedElement = navigateToElement(from: effectiveElement, pathHint: pathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + effectiveElement = navigatedElement + } else { + let errorMessage = "Element not found via path hint: \(pathHintString)" + dLog("[AXorcist.handleQuery] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + } + + // Check if this is an app-only locator (only application/bundle_id/pid/path criteria) + let appSpecifiers = ["application", "bundle_id", "pid", "path"] + let criteriaKeys = locator.criteria.keys + let isAppOnlyLocator = criteriaKeys.allSatisfy { appSpecifiers.contains($0) } && criteriaKeys.count == 1 + + var foundElement: Element? = nil + + if isAppOnlyLocator { + dLog("[AXorcist.handleQuery] Locator is app-only (criteria: \(locator.criteria)). Using appElement directly.") + foundElement = effectiveElement + } else { + dLog("[AXorcist.handleQuery] Locator contains element-specific criteria. Proceeding with search.") + var searchStartElementForLocator = appElement + + if let rootPathHint = locator.root_element_path_hint, !rootPathHint.isEmpty { + let rootPathHintString = rootPathHint.joined(separator: " -> ") + _ = rootPathHintString // Silences compiler warning + dLog("[AXorcist.handleQuery] Locator has root_element_path_hint: \(rootPathHintString). Navigating from app element first.") + guard let containerElement = navigateToElement(from: appElement, pathHint: rootPathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMessage = "Container for locator not found via root_element_path_hint: \(rootPathHintString)" + dLog("[AXorcist.handleQuery] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + searchStartElementForLocator = containerElement + let containerDescription = searchStartElementForLocator.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + _ = containerDescription // Silences compiler warning + dLog("[AXorcist.handleQuery] Searching with locator within container found by root_element_path_hint: \(containerDescription)") + } else { + searchStartElementForLocator = effectiveElement + let searchDescription = searchStartElementForLocator.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + _ = searchDescription // Silences compiler warning + dLog("[AXorcist.handleQuery] Searching with locator from element (determined by main path_hint or app root): \(searchDescription)") + } + + let finalSearchTarget = (pathHint != nil && !pathHint!.isEmpty) ? effectiveElement : searchStartElementForLocator + + foundElement = search( + element: finalSearchTarget, + locator: locator, + requireAction: locator.requireAction, + maxDepth: maxDepth ?? DEFAULT_MAX_DEPTH_SEARCH, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + } + + if let elementToQuery = foundElement { + let elementDescription = elementToQuery.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + _ = elementDescription // Silences compiler warning + dLog("[AXorcist.handleQuery] Element found: \(elementDescription). Fetching attributes...") + + var attributes = getElementAttributes( + elementToQuery, + requestedAttributes: requestedAttributes ?? [], + forMultiDefault: false, + targetRole: locator.criteria[kAXRoleAttribute], + outputFormat: outputFormat ?? .smart, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + if outputFormat == .json_string { + attributes = encodeAttributesToJSONStringRepresentation(attributes) + } + + let elementPathArray = elementToQuery.generatePathArray(upTo: appElement, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + let axElement = AXElement(attributes: attributes, path: elementPathArray) + + dLog("[AXorcist.handleQuery] Successfully found and processed element with query.") + return HandlerResponse(data: axElement, error: nil, debug_logs: currentDebugLogs) + } else { + let errorMessage = "No element matches query criteria with locator: \(String(describing: locator))" + dLog("[AXorcist.handleQuery] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + } + + // Handle describe element command - provides comprehensive details about a specific element + @MainActor + public func handleDescribeElement( + for appIdentifierOrNil: String? = nil, + locator: Locator, + pathHint: [String]? = nil, + maxDepth: Int? = nil, + outputFormat: OutputFormat? = nil, + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] + ) -> HandlerResponse { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + + let appIdentifier = appIdentifierOrNil ?? focusedAppKeyValue + dLog("[AXorcist.handleDescribeElement] Handling for app: \(appIdentifier)") + + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMessage = "Application not found: \(appIdentifier)" + dLog("[AXorcist.handleDescribeElement] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + + var effectiveElement = appElement + if let pathHint = pathHint, !pathHint.isEmpty { + let pathHintString = pathHint.joined(separator: " -> ") + _ = pathHintString // Silences compiler warning + dLog("[AXorcist.handleDescribeElement] Navigating with path_hint: \(pathHintString)") + if let navigatedElement = navigateToElement(from: effectiveElement, pathHint: pathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + effectiveElement = navigatedElement + } else { + let errorMessage = "Element not found via path hint for describe_element: \(pathHintString)" + dLog("[AXorcist.handleDescribeElement] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + } + + let rootElementDescription = effectiveElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + _ = rootElementDescription // Silences compiler warning + dLog("[AXorcist.handleDescribeElement] Searching for element with locator: \(locator.criteria) from root: \(rootElementDescription)") + let foundElement = search( + element: effectiveElement, + locator: locator, + requireAction: locator.requireAction, + maxDepth: maxDepth ?? DEFAULT_MAX_DEPTH_SEARCH, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + if let elementToDescribe = foundElement { + let elementDescription = elementToDescribe.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + _ = elementDescription // Silences compiler warning + dLog("[AXorcist.handleDescribeElement] Element found: \(elementDescription). Describing with verbose output...") + + // For describe_element, we typically want ALL attributes with verbose output + var attributes = getElementAttributes( + elementToDescribe, + requestedAttributes: [], // Empty means 'all standard' or 'all known' + forMultiDefault: false, + targetRole: locator.criteria[kAXRoleAttribute], + outputFormat: .verbose, // Describe implies verbose + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + if outputFormat == .json_string { + attributes = encodeAttributesToJSONStringRepresentation(attributes) + } + + let elementPathArray = elementToDescribe.generatePathArray(upTo: appElement, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + let axElement = AXElement(attributes: attributes, path: elementPathArray) + + dLog("[AXorcist.handleDescribeElement] Successfully described element \(elementToDescribe.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs)).") + return HandlerResponse(data: axElement, error: nil, debug_logs: currentDebugLogs) + } else { + let errorMessage = "No element found for describe_element with locator: \(String(describing: locator))" + dLog("[AXorcist.handleDescribeElement] \(errorMessage)") + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + } + + // Add other public API methods here as they are refactored or created. + // For example: + // public func handlePerformAction(...) async -> HandlerResponse { ... } + + @MainActor + public func handlePerformAction( + for appIdentifierOrNil: String? = nil, + locator: Locator, + pathHint: [String]? = nil, + actionName: String, + actionValue: AnyCodable?, + maxDepth: Int? = nil, + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] + ) -> HandlerResponse { + + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + + let appIdentifier = appIdentifierOrNil ?? focusedAppKeyValue + dLog("[AXorcist.handlePerformAction] Handling for app: \(appIdentifier), action: \(actionName)") + + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let error = "[AXorcist.handlePerformAction] Failed to get application element for identifier: \(appIdentifier)" + dLog(error) + return HandlerResponse(data: nil, error: error, debug_logs: currentDebugLogs) + } + + var effectiveElement = appElement + + if let pathHint = pathHint, !pathHint.isEmpty { + dLog("[AXorcist.handlePerformAction] Navigating with path_hint: \(pathHint.joined(separator: " -> "))") + guard let navigatedElement = navigateToElement(from: effectiveElement, pathHint: pathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let error = "[AXorcist.handlePerformAction] Failed to navigate using path hint: \(pathHint.joined(separator: " -> "))" + dLog(error) + return HandlerResponse(data: nil, error: error, debug_logs: currentDebugLogs) + } + effectiveElement = navigatedElement + } + + dLog("[AXorcist.handlePerformAction] Searching for element with locator: \(locator.criteria) from root: \(effectiveElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs))") + guard let foundElement = search(element: effectiveElement, locator: locator, requireAction: locator.requireAction, maxDepth: maxDepth ?? DEFAULT_MAX_DEPTH_SEARCH, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let error = "[AXorcist.handlePerformAction] Failed to find element with locator: \(locator)" + dLog(error) + return HandlerResponse(data: nil, error: error, debug_logs: currentDebugLogs) + } + + dLog("[AXorcist.handlePerformAction] Found element: \(foundElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs))") + if let actionValue = actionValue { + // Attempt to get a string representation of actionValue.value for logging + // This is a basic attempt; complex types might not log well. + let valueDescription = String(describing: actionValue.value) + dLog("[AXorcist.handlePerformAction] Performing action '\(actionName)' with value: \(valueDescription)") + } else { + dLog("[AXorcist.handlePerformAction] Performing action '\(actionName)'") + } + + var errorMessage: String? + var axStatus: AXError = .success // Initialize to success + + switch actionName.lowercased() { + case "press": + axStatus = AXUIElementPerformAction(foundElement.underlyingElement, kAXPressAction as CFString) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to perform press action: \(axErrorToString(axStatus))" + } + case "increment": + axStatus = AXUIElementPerformAction(foundElement.underlyingElement, kAXIncrementAction as CFString) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to perform increment action: \(axErrorToString(axStatus))" + } + case "decrement": + axStatus = AXUIElementPerformAction(foundElement.underlyingElement, kAXDecrementAction as CFString) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to perform decrement action: \(axErrorToString(axStatus))" + } + case "showmenu": + axStatus = AXUIElementPerformAction(foundElement.underlyingElement, kAXShowMenuAction as CFString) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to perform showmenu action: \(axErrorToString(axStatus))" + } + case "pick": + axStatus = AXUIElementPerformAction(foundElement.underlyingElement, kAXPickAction as CFString) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to perform pick action: \(axErrorToString(axStatus))" + } + case "cancel": + axStatus = AXUIElementPerformAction(foundElement.underlyingElement, kAXCancelAction as CFString) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to perform cancel action: \(axErrorToString(axStatus))" + } + default: + if actionName.hasPrefix("AX") { + axStatus = AXUIElementPerformAction(foundElement.underlyingElement, actionName as CFString) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to perform action '\(actionName)': \(axErrorToString(axStatus))" + } + } else { + if let actionValue = actionValue { + var cfValue: CFTypeRef? + // Convert basic Swift types to CFTypeRef for setting attributes + switch actionValue.value { + case let stringValue as String: + cfValue = stringValue as CFString + case let boolValue as Bool: + cfValue = boolValue as CFBoolean + case let intValue as Int: + var number = intValue + cfValue = CFNumberCreate(kCFAllocatorDefault, .intType, &number) + case let doubleValue as Double: + var number = doubleValue + cfValue = CFNumberCreate(kCFAllocatorDefault, .doubleType, &number) + // TODO: Consider other CFNumber types if necessary (CGFloat, etc.) + // TODO: Consider CFArray, CFDictionary if complex values are needed. + default: + // For other types, attempt a direct cast if possible, or log/error. + // This is a simplification; robust conversion is more involved. + if CFGetTypeID(actionValue.value as AnyObject) != 0 { // Basic check if it *might* be a CFType + cfValue = actionValue.value as AnyObject // bridge from Any to AnyObject then to CFTypeRef + dLog("[AXorcist.handlePerformAction] Warning: Attempting to use actionValue of type '\(type(of: actionValue.value))' directly as CFTypeRef for attribute '\(actionName)'. This might not work as expected.") + } else { + errorMessage = "[AXorcist.handlePerformAction] Unsupported value type '\(type(of: actionValue.value))' for attribute '\(actionName)'. Cannot convert to CFTypeRef." + dLog(errorMessage!) + } + } + + if errorMessage == nil, let finalCFValue = cfValue { + axStatus = AXUIElementSetAttributeValue(foundElement.underlyingElement, actionName as CFString, finalCFValue) + if axStatus != .success { + errorMessage = "[AXorcist.handlePerformAction] Failed to set attribute '\(actionName)' to value '\(String(describing: actionValue.value))': \(axErrorToString(axStatus))" + } + } else if errorMessage == nil { // cfValue was nil, means conversion failed earlier but wasn't caught by the default error + errorMessage = "[AXorcist.handlePerformAction] Failed to convert value for attribute '\(actionName)' to a CoreFoundation type." + } + } else { + errorMessage = "[AXorcist.handlePerformAction] Unknown action '\(actionName)' and no action_value provided to interpret as an attribute." + } + } + } + + if let currentErrorMessage = errorMessage { + dLog(currentErrorMessage) + return HandlerResponse(data: nil, error: currentErrorMessage, debug_logs: currentDebugLogs) + } + + dLog("[AXorcist.handlePerformAction] Action '\(actionName)' performed successfully.") + return HandlerResponse(data: nil, error: nil, debug_logs: currentDebugLogs) + } + + @MainActor + public func handleExtractText( + for appIdentifierOrNil: String? = nil, + locator: Locator, + pathHint: [String]? = nil, + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] + ) -> HandlerResponse { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append("[handleExtractText] \(message)") + } + } + + let appIdentifier = appIdentifierOrNil ?? focusedAppKeyValue + dLog("Starting text extraction for app: \(appIdentifier)") + + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMessage = "Failed to get application element for \(appIdentifier)" + dLog(errorMessage) + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + + var effectiveElement = appElement + if let pathHint = pathHint, !pathHint.isEmpty { + dLog("Navigating to element using path hint: \(pathHint.joined(separator: " -> "))") + guard let navigatedElement = navigateToElement(from: appElement, pathHint: pathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMessage = "Failed to navigate to element using path hint: \(pathHint.joined(separator: " -> "))" + dLog(errorMessage) + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + effectiveElement = navigatedElement + } + + dLog("Searching for target element with locator: \(locator)") + // Assuming DEFAULT_MAX_DEPTH_SEARCH is defined elsewhere, e.g., in AXConstants.swift or similar. + // If not, replace with a sensible default like 10. + guard let foundElement = search(element: effectiveElement, locator: locator, requireAction: locator.requireAction, maxDepth: DEFAULT_MAX_DEPTH_SEARCH, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + let errorMessage = "Target element not found for locator: \(locator)" + dLog(errorMessage) + return HandlerResponse(data: nil, error: errorMessage, debug_logs: currentDebugLogs) + } + + dLog("Target element found: \(foundElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs)), attempting to extract text") + var attributes: [String: AnyCodable] = [:] + var extractedValueText: String? + var extractedSelectedText: String? + + var cfValue: CFTypeRef? + if AXUIElementCopyAttributeValue(foundElement.underlyingElement, kAXValueAttribute as CFString, &cfValue) == .success, let value = cfValue { + if CFGetTypeID(value) == CFStringGetTypeID() { + extractedValueText = (value as! CFString) as String + if let extractedValueText = extractedValueText, !extractedValueText.isEmpty { + attributes["extractedValue"] = AnyCodable(extractedValueText) + dLog("Extracted text from kAXValueAttribute (length: \(extractedValueText.count)): \(extractedValueText.prefix(80))...") + } else { + dLog("kAXValueAttribute was empty or not a string.") + } + } else { + dLog("kAXValueAttribute was present but not a CFString. TypeID: \(CFGetTypeID(value))") + } + } else { + dLog("Failed to get kAXValueAttribute or it was nil.") + } + + cfValue = nil // Reset for next attribute + if AXUIElementCopyAttributeValue(foundElement.underlyingElement, kAXSelectedTextAttribute as CFString, &cfValue) == .success, let selectedValue = cfValue { + if CFGetTypeID(selectedValue) == CFStringGetTypeID() { + extractedSelectedText = (selectedValue as! CFString) as String + if let extractedSelectedText = extractedSelectedText, !extractedSelectedText.isEmpty { + attributes["extractedSelectedText"] = AnyCodable(extractedSelectedText) + dLog("Extracted selected text from kAXSelectedTextAttribute (length: \(extractedSelectedText.count)): \(extractedSelectedText.prefix(80))...") + } else { + dLog("kAXSelectedTextAttribute was empty or not a string.") + } + } else { + dLog("kAXSelectedTextAttribute was present but not a CFString. TypeID: \(CFGetTypeID(selectedValue))") + } + } else { + dLog("Failed to get kAXSelectedTextAttribute or it was nil.") + } + + + if attributes.isEmpty { + dLog("Warning: No text could be extracted from the element via kAXValueAttribute or kAXSelectedTextAttribute.") + // It's not an error, just means no text content via these primary attributes. + // Other attributes might still be relevant, so we return the element. + } + + let elementPathArray = foundElement.generatePathArray(upTo: appElement, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + // Include any other relevant attributes if needed, for now just the extracted text + let axElement = AXElement(attributes: attributes, path: elementPathArray) + + dLog("Text extraction process completed.") + return HandlerResponse(data: axElement, error: nil, debug_logs: currentDebugLogs) + } + + @MainActor + public func handleBatchCommands( + batchCommandID: String, // The ID of the overall batch command + subCommands: [CommandEnvelope], // The array of sub-commands to process + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] + ) -> [HandlerResponse] { + // Local debug logging function + func dLog(_ message: String, subCommandID: String? = nil) { + if isDebugLoggingEnabled { + let prefix = subCommandID != nil ? "[AXorcist.handleBatchCommands][SubCmdID: \(subCommandID!)]" : "[AXorcist.handleBatchCommands][BatchID: \(batchCommandID)]" + currentDebugLogs.append("\(prefix) \(message)") + } + } + + dLog("Starting batch processing with \(subCommands.count) sub-commands.") + + var batchResults: [HandlerResponse] = [] + + for subCommandEnvelope in subCommands { + let subCmdID = subCommandEnvelope.command_id + // Create a temporary log array for this specific sub-command to pass to handlers if needed, + // or decide if currentDebugLogs should be directly mutated by sub-handlers and reflect cumulative logs. + // For simplicity here, let's assume sub-handlers append to the main currentDebugLogs. + dLog("Processing sub-command: \(subCmdID), type: \(subCommandEnvelope.command)", subCommandID: subCmdID) + + var subCommandResponse: HandlerResponse + + switch subCommandEnvelope.command { + case .getFocusedElement: + subCommandResponse = self.handleGetFocusedElement( + for: subCommandEnvelope.application, + requestedAttributes: subCommandEnvelope.attributes, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs // Pass the main log array + ) + + case .getAttributes: + guard let locator = subCommandEnvelope.locator else { + let errorMsg = "Locator missing for getAttributes in batch (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) // Keep debug_logs nil for specific error, main logs will have the dLog entry + break + } + subCommandResponse = self.handleGetAttributes( + for: subCommandEnvelope.application, + locator: locator, + requestedAttributes: subCommandEnvelope.attributes, + pathHint: subCommandEnvelope.path_hint, + maxDepth: subCommandEnvelope.max_elements, + outputFormat: subCommandEnvelope.output_format, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + case .query: + guard let locator = subCommandEnvelope.locator else { + let errorMsg = "Locator missing for query in batch (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) + break + } + subCommandResponse = self.handleQuery( + for: subCommandEnvelope.application, + locator: locator, + pathHint: subCommandEnvelope.path_hint, + maxDepth: subCommandEnvelope.max_elements, + requestedAttributes: subCommandEnvelope.attributes, + outputFormat: subCommandEnvelope.output_format, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + case .describeElement: + guard let locator = subCommandEnvelope.locator else { + let errorMsg = "Locator missing for describeElement in batch (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) + break + } + subCommandResponse = self.handleDescribeElement( + for: subCommandEnvelope.application, + locator: locator, + pathHint: subCommandEnvelope.path_hint, + maxDepth: subCommandEnvelope.max_elements, + outputFormat: subCommandEnvelope.output_format, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + case .performAction: + guard let locator = subCommandEnvelope.locator else { + let errorMsg = "Locator missing for performAction in batch (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) + break + } + guard let actionName = subCommandEnvelope.action_name else { + let errorMsg = "Action name missing for performAction in batch (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) + break + } + subCommandResponse = self.handlePerformAction( + for: subCommandEnvelope.application, + locator: locator, + pathHint: subCommandEnvelope.path_hint, + actionName: actionName, + actionValue: subCommandEnvelope.action_value, + maxDepth: subCommandEnvelope.max_elements, // Added maxDepth, though performAction doesn't currently use it directly, for consistency + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + case .extractText: + guard let locator = subCommandEnvelope.locator else { + let errorMsg = "Locator missing for extractText in batch (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) + break + } + subCommandResponse = self.handleExtractText( + for: subCommandEnvelope.application, + locator: locator, + pathHint: subCommandEnvelope.path_hint, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs + ) + + case .ping: + let pingMsg = "Ping command handled within batch (sub-command ID: \(subCmdID))" + dLog(pingMsg, subCommandID: subCmdID) + // For ping, the handlerResponse itself won't carry much data from AXorcist, + // but it should indicate success and carry the logs up to this point for this sub-command. + subCommandResponse = HandlerResponse(data: nil, error: nil, debug_logs: isDebugLoggingEnabled ? currentDebugLogs : nil) + + // .batch command cannot be nested. .collectAll is also not handled by AXorcist lib directly. + case .collectAll, .batch: + let errorMsg = "Command type '\(subCommandEnvelope.command)' not supported within batch execution by AXorcist (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) + + // default case for any command types that might be added to CommandType enum + // but not handled by this switch statement within handleBatchCommands. + // This is distinct from commands axorc itself might handle outside of AXorcist library. + // @unknown default: // This would be better if Swift enums allowed it easily here for non-frozen enums from other modules. + // Since CommandType is in axorc, this default captures any CommandType case not explicitly handled above. + default: + let errorMsg = "Unknown or unhandled command type '\(subCommandEnvelope.command)' in batch processing within AXorcist (sub-command ID: \(subCmdID))" + dLog(errorMsg, subCommandID: subCmdID) + subCommandResponse = HandlerResponse(data: nil, error: errorMsg, debug_logs: nil) + } + batchResults.append(subCommandResponse) + } + + dLog("Completed batch command processing, returning \(batchResults.count) results.") + return batchResults + } + + @MainActor + public func handleCollectAll( + for appIdentifierOrNil: String?, + locator: Locator?, + pathHint: [String]?, + maxDepth: Int?, + requestedAttributes: [String]?, + outputFormat: OutputFormat?, + isDebugLoggingEnabled: Bool, + currentDebugLogs: [String] // No longer inout, logs from caller + ) -> HandlerResponse { + self.recursiveCallDebugLogs.removeAll() + self.recursiveCallDebugLogs.append(contentsOf: currentDebugLogs) // Incorporate initial logs + + // Local dLog now appends to self.recursiveCallDebugLogs + func dLog(_ message: String) { + if isDebugLoggingEnabled { + let logMessage = "[AXorcist.handleCollectAll] \(message)" + self.recursiveCallDebugLogs.append(logMessage) + } + } + + dLog("Starting handleCollectAll") + + let appIdentifier = appIdentifierOrNil ?? focusedAppKeyValue + dLog("Using app identifier: \(appIdentifier)") + + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs) else { + let errorMsg = "Failed to get app element for identifier: \(appIdentifier)" + dLog(errorMsg) + // Return all accumulated logs up to this point + return HandlerResponse(data: nil, error: errorMsg, debug_logs: self.recursiveCallDebugLogs) + } + + var startElement: Element + if let hint = pathHint, !hint.isEmpty { + dLog("Navigating to path hint: \(hint.joined(separator: " -> "))") + guard let navigatedElement = navigateToElement(from: appElement, pathHint: hint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs) else { + let errorMsg = "Failed to navigate to path: \(hint.joined(separator: " -> "))" + dLog(errorMsg) + return HandlerResponse(data: nil, error: errorMsg, debug_logs: self.recursiveCallDebugLogs) + } + startElement = navigatedElement + } else { + dLog("Using app element as start element") + startElement = appElement + } + + var collectedAXElements: [AXElement] = [] + let effectiveMaxDepth = maxDepth ?? 8 + dLog("Max collection depth: \(effectiveMaxDepth)") + + var collectRecursively: ((AXUIElement, Int) -> Void)! + collectRecursively = { axUIElement, currentDepth in + if currentDepth > effectiveMaxDepth { + // Pass &self.recursiveCallDebugLogs to briefDescription + dLog("Reached max depth \(effectiveMaxDepth) at element \(Element(axUIElement).briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs)), stopping recursion for this branch.") + return + } + + let currentElement = Element(axUIElement) + + var shouldIncludeElement = true // Default to include if no locator + if let loc = locator { + let matchStatus = evaluateElementAgainstCriteria( + element: currentElement, + locator: loc, + actionToVerify: loc.requireAction, // Pass requireAction from locator + depth: currentDepth, // Pass currentDepth + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: &self.recursiveCallDebugLogs + ) + if matchStatus != .fullMatch { + shouldIncludeElement = false + // Log if not a full match, but still recurse for children + dLog("Element \(currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs)) at depth \(currentDepth) did not fully match locator (status: \(matchStatus)), not collecting it.") + } + } + + if shouldIncludeElement { + dLog("Collecting element \(currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs)) at depth \(currentDepth)") + + let fetchedAttrs = getElementAttributes( + currentElement, + requestedAttributes: requestedAttributes ?? [], + forMultiDefault: true, + targetRole: nil as String?, + outputFormat: outputFormat ?? .smart, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: &self.recursiveCallDebugLogs // Pass self.recursiveCallDebugLogs + ) + + let elementPath = currentElement.generatePathArray( + upTo: appElement, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: &self.recursiveCallDebugLogs // Pass self.recursiveCallDebugLogs + ) + + let axElement = AXElement(attributes: fetchedAttrs, path: elementPath) + collectedAXElements.append(axElement) + } else if locator != nil { + dLog("Element \(currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs)) did not match locator. Still checking children.") + } + + var childrenRef: CFTypeRef? + let childrenResult = AXUIElementCopyAttributeValue(axUIElement, kAXChildrenAttribute as CFString, &childrenRef) + + if childrenResult == .success, let children = childrenRef as? [AXUIElement] { + dLog("Element \(currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs)) has \(children.count) children at depth \(currentDepth). Recursing.") + for childElement in children { + collectRecursively(childElement, currentDepth + 1) + } + } else if childrenResult != .success { + dLog("Failed to get children for element \(currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs)): \(axErrorToString(childrenResult))") + } else { + dLog("No children found for element \(currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs)) at depth \(currentDepth)") + } + } + + dLog("Starting recursive collection from start element: \(startElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs))") + collectRecursively(startElement.underlyingElement, 0) + + dLog("Collection complete. Found \(collectedAXElements.count) elements matching criteria (if any). Naming them 'collected_elements' in response.") + + let responseDataElement = AXElement( + attributes: ["collected_elements": AnyCodable(collectedAXElements)], + path: startElement.generatePathArray(upTo: appElement, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &self.recursiveCallDebugLogs) + ) + + return HandlerResponse(data: responseDataElement, error: nil, debug_logs: self.recursiveCallDebugLogs) + } + + internal func getElementAttributes(for axElement: AXUIElement, requestedAttributes: [String]?, outputFormat: OutputFormat, pathHint: [String]?, currentDebugLogs: inout [String]) -> (attributes: ElementAttributes, collectedAttrData: [String: AttributeData]) { + var attributesToReturn: ElementAttributes = [:] + var collectedAttributesForComputedName: [String: AttributeData] = [:] // Still collect for computed name + + dLog("getElementAttributes starting for element: \(axElement.descriptionForLogs), format: \(outputFormat.rawValue)", ¤tDebugLogs) + + // Temporarily, only process ComputedName + // First, we need to fetch attributes that might be used by generateComputedName, e.g., AXRole, AXTitle, AXValue, etc. + // This part remains similar to ensure `collectedAttributesForComputedName` is populated. + let attributesNeededForComputedName = [kAXRoleAttribute, kAXTitleAttribute, kAXValueAttribute, kAXDescriptionAttribute, kAXPlaceholderValueAttribute, "AXIdentifier", "AXLabel"] + for attrNameCFS in attributesNeededForComputedName { + let attrName = attrNameCFS as String + let (value, source) = getAttributeValueAndSource(for: axElement, attributeName: attrName, outputFormat: .smart) // Use .smart to get raw values + collectedAttributesForComputedName[attrName] = AttributeData(value: AnyCodable(value), source: source) + } + + if let computedNameString = generateComputedName(for: axElement, from: collectedAttributesForComputedName) { + attributesToReturn["ComputedName"] = AnyCodable(computedNameString) + dLog("Added ComputedName: \(computedNameString)", ¤tDebugLogs) + } + + // Skip all other attribute processing for this debug step + dLog("TEMPORARY DEBUG: Skipped all other attribute processing.", ¤tDebugLogs) + + // Still need to populate AXActionNames for consistency if other parts of axorc rely on it + // but ensure it uses only encodable values. + var actionNames: [String] = [] + // Simplified action fetching or make it empty for now to avoid encoding issues from it + // Example: actionNames.append("debug_action") + attributesToReturn[kAXActionNamesAttribute as String] = AnyCodable(actionNames) // Ensure this is an array of strings + + dLog("getElementAttributes finished. Result keys: \(attributesToReturn.keys.joined(separator: \", \"))", ¤tDebugLogs) + return (attributesToReturn, collectedAttributesForComputedName) // Return the possibly minimal attributes + } + + // Helper to get an attribute's value and its source (e.g., direct, placeholder, computed) + // ... existing code ... +} \ No newline at end of file diff --git a/Sources/AXorcist/Commands/GetAttributesCommandHandler.swift b/Sources/AXorcist/Commands/GetAttributesCommandHandler.swift new file mode 100644 index 0000000..63462ba --- /dev/null +++ b/Sources/AXorcist/Commands/GetAttributesCommandHandler.swift @@ -0,0 +1,71 @@ +import Foundation +import ApplicationServices +import AppKit + +// Placeholder for GetAttributesCommand if it were a distinct struct +// public struct GetAttributesCommand: Codable { ... } + +@MainActor +public func handleGetAttributes(cmd: CommandEnvelope, isDebugLoggingEnabled: Bool) throws -> QueryResponse { + var handlerLogs: [String] = [] // Local logs for this handler + func dLog(_ message: String) { if isDebugLoggingEnabled { handlerLogs.append(message) } } + dLog("Handling get_attributes command for app: \(cmd.application ?? "focused app")") + + let appIdentifier = cmd.application ?? focusedApplicationKey + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs) else { + let errorMessage = "Application not found: \(appIdentifier)" + dLog("handleGetAttributes: \(errorMessage)") + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: errorMessage, debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } + + // Find element to get attributes from + var effectiveElement = appElement + if let pathHint = cmd.path_hint, !pathHint.isEmpty { + dLog("handleGetAttributes: Navigating with path_hint: \(pathHint.joined(separator: " -> "))") + if let navigatedElement = navigateToElement(from: effectiveElement, pathHint: pathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs) { + effectiveElement = navigatedElement + } else { + let errorMessage = "Element not found via path hint: \(pathHint.joined(separator: " -> "))" + dLog("handleGetAttributes: \(errorMessage)") + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: errorMessage, debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } + } + + guard let locator = cmd.locator else { + let errorMessage = "Locator not provided for get_attributes." + dLog("handleGetAttributes: \(errorMessage)") + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: errorMessage, debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } + + dLog("handleGetAttributes: Searching for element with locator: \(locator.criteria) from root: \(effectiveElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs))") + let foundElement = search( + element: effectiveElement, + locator: locator, + requireAction: locator.requireAction, + maxDepth: cmd.max_elements ?? DEFAULT_MAX_DEPTH_SEARCH, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: &handlerLogs + ) + + if let elementToQuery = foundElement { + dLog("handleGetAttributes: Element found: \(elementToQuery.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs)). Fetching attributes: \(cmd.attributes ?? ["all"])...") + var attributes = getElementAttributes( + elementToQuery, + requestedAttributes: cmd.attributes ?? [], + forMultiDefault: false, + targetRole: locator.criteria[kAXRoleAttribute], + outputFormat: cmd.output_format ?? .smart, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: &handlerLogs + ) + if cmd.output_format == .json_string { + attributes = encodeAttributesToJSONStringRepresentation(attributes) + } + dLog("Successfully fetched attributes for element \(elementToQuery.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs)).") + return QueryResponse(command_id: cmd.command_id, attributes: attributes, error: nil, debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } else { + let errorMessage = "No element found for get_attributes with locator: \(String(describing: locator))" + dLog("handleGetAttributes: \(errorMessage)") + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: errorMessage, debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Commands/QueryCommandHandler.swift b/Sources/AXorcist/Commands/QueryCommandHandler.swift new file mode 100644 index 0000000..659bc49 --- /dev/null +++ b/Sources/AXorcist/Commands/QueryCommandHandler.swift @@ -0,0 +1,92 @@ +import Foundation +import ApplicationServices +import AppKit + +// Note: Relies on applicationElement, navigateToElement, search, getElementAttributes, +// DEFAULT_MAX_DEPTH_SEARCH, CommandEnvelope, QueryResponse, Locator. + +@MainActor +public func handleQuery(cmd: CommandEnvelope, isDebugLoggingEnabled: Bool) async throws -> QueryResponse { + var handlerLogs: [String] = [] // Local logs for this handler + func dLog(_ message: String) { if isDebugLoggingEnabled { handlerLogs.append(message) } } + + let appIdentifier = cmd.application ?? focusedApplicationKey + dLog("Handling query for app: \(appIdentifier)") + + // Pass logging parameters to applicationElement + guard let appElement = applicationElement(for: appIdentifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs) else { + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: "Application not found: \(appIdentifier)", debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } + + var effectiveElement = appElement + if let pathHint = cmd.path_hint, !pathHint.isEmpty { + dLog("Navigating with path_hint: \(pathHint.joined(separator: " -> "))") + // Pass logging parameters to navigateToElement + if let navigatedElement = navigateToElement(from: effectiveElement, pathHint: pathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs) { + effectiveElement = navigatedElement + } else { + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: "Element not found via path hint: \(pathHint.joined(separator: " -> "))", debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } + } + + guard let locator = cmd.locator else { + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: "Locator not provided in command.", debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } + + let appSpecifiers = ["application", "bundle_id", "pid", "path"] + let criteriaKeys = locator.criteria.keys + let isAppOnlyLocator = criteriaKeys.allSatisfy { appSpecifiers.contains($0) } && criteriaKeys.count == 1 + + var foundElement: Element? = nil + + if isAppOnlyLocator { + dLog("Locator is app-only (criteria: \(locator.criteria)). Using appElement directly.") + foundElement = effectiveElement + } else { + dLog("Locator contains element-specific criteria or is complex. Proceeding with search.") + var searchStartElementForLocator = appElement + if let rootPathHint = locator.root_element_path_hint, !rootPathHint.isEmpty { + dLog("Locator has root_element_path_hint: \(rootPathHint.joined(separator: " -> ")). Navigating from app element first.") + // Pass logging parameters to navigateToElement + guard let containerElement = navigateToElement(from: appElement, pathHint: rootPathHint, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs) else { + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: "Container for locator not found via root_element_path_hint: \(rootPathHint.joined(separator: " -> "))", debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } + searchStartElementForLocator = containerElement + dLog("Searching with locator within container found by root_element_path_hint: \(searchStartElementForLocator.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs))") + } else { + searchStartElementForLocator = effectiveElement + dLog("Searching with locator from element (determined by main path_hint or app root): \(searchStartElementForLocator.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &handlerLogs))") + } + + let finalSearchTarget = (cmd.path_hint != nil && !cmd.path_hint!.isEmpty) ? effectiveElement : searchStartElementForLocator + + // Pass logging parameters to search + foundElement = search( + element: finalSearchTarget, + locator: locator, + requireAction: locator.requireAction, + maxDepth: cmd.max_elements ?? DEFAULT_MAX_DEPTH_SEARCH, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: &handlerLogs + ) + } + + if let elementToQuery = foundElement { + // Pass logging parameters to getElementAttributes + var attributes = getElementAttributes( + elementToQuery, + requestedAttributes: cmd.attributes ?? [], + forMultiDefault: false, + targetRole: locator.criteria[kAXRoleAttribute], + outputFormat: cmd.output_format ?? .smart, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: &handlerLogs + ) + if cmd.output_format == .json_string { + attributes = encodeAttributesToJSONStringRepresentation(attributes) + } + return QueryResponse(command_id: cmd.command_id, attributes: attributes, error: nil, debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } else { + return QueryResponse(command_id: cmd.command_id, attributes: nil, error: "No element matches single query criteria with locator or app-only locator failed to resolve.", debug_logs: isDebugLoggingEnabled ? handlerLogs : nil) + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/AccessibilityConstants.swift b/Sources/AXorcist/Core/AccessibilityConstants.swift new file mode 100644 index 0000000..ab93a4b --- /dev/null +++ b/Sources/AXorcist/Core/AccessibilityConstants.swift @@ -0,0 +1,201 @@ +// AccessibilityConstants.swift - Defines global constants used throughout the accessibility helper + +import Foundation +import ApplicationServices // Added for AXError type +import AppKit // Added for NSAccessibility + +// Configuration Constants +public let MAX_COLLECT_ALL_HITS = 200 // Default max elements for collect_all if not specified in command +public let DEFAULT_MAX_DEPTH_SEARCH = 20 // Default max recursion depth for search +public let DEFAULT_MAX_DEPTH_COLLECT_ALL = 15 // Default max recursion depth for collect_all +public let AX_BINARY_VERSION = "1.1.7" // Updated version +public let BINARY_VERSION = "1.1.7" // Updated version without AX prefix + +// Standard Accessibility Attributes - Values should match CFSTR defined in AXAttributeConstants.h +public let kAXRoleAttribute = "AXRole" // Reverted to String literal +public let kAXSubroleAttribute = "AXSubrole" +public let kAXRoleDescriptionAttribute = "AXRoleDescription" +public let kAXTitleAttribute = "AXTitle" +public let kAXValueAttribute = "AXValue" +public let kAXValueDescriptionAttribute = "AXValueDescription" // New +public let kAXDescriptionAttribute = "AXDescription" +public let kAXHelpAttribute = "AXHelp" +public let kAXIdentifierAttribute = "AXIdentifier" +public let kAXPlaceholderValueAttribute = "AXPlaceholderValue" +public let kAXLabelUIElementAttribute = "AXLabelUIElement" +public let kAXTitleUIElementAttribute = "AXTitleUIElement" +public let kAXLabelValueAttribute = "AXLabelValue" +public let kAXElementBusyAttribute = "AXElementBusy" // New +public let kAXAlternateUIVisibleAttribute = "AXAlternateUIVisible" // New + +public let kAXChildrenAttribute = "AXChildren" +public let kAXParentAttribute = "AXParent" +public let kAXWindowsAttribute = "AXWindows" +public let kAXMainWindowAttribute = "AXMainWindow" +public let kAXFocusedWindowAttribute = "AXFocusedWindow" +public let kAXFocusedUIElementAttribute = "AXFocusedUIElement" + +public let kAXEnabledAttribute = "AXEnabled" +public let kAXFocusedAttribute = "AXFocused" +public let kAXMainAttribute = "AXMain" // Window-specific +public let kAXMinimizedAttribute = "AXMinimized" // New, Window-specific +public let kAXCloseButtonAttribute = "AXCloseButton" // New, Window-specific +public let kAXZoomButtonAttribute = "AXZoomButton" // New, Window-specific +public let kAXMinimizeButtonAttribute = "AXMinimizeButton" // New, Window-specific +public let kAXFullScreenButtonAttribute = "AXFullScreenButton" // New, Window-specific +public let kAXDefaultButtonAttribute = "AXDefaultButton" // New, Window-specific +public let kAXCancelButtonAttribute = "AXCancelButton" // New, Window-specific +public let kAXGrowAreaAttribute = "AXGrowArea" // New, Window-specific +public let kAXModalAttribute = "AXModal" // New, Window-specific + +public let kAXMenuBarAttribute = "AXMenuBar" // New, App-specific +public let kAXFrontmostAttribute = "AXFrontmost" // New, App-specific +public let kAXHiddenAttribute = "AXHidden" // New, App-specific + +public let kAXPositionAttribute = "AXPosition" +public let kAXSizeAttribute = "AXSize" + +// Value attributes +public let kAXMinValueAttribute = "AXMinValue" // New +public let kAXMaxValueAttribute = "AXMaxValue" // New +public let kAXValueIncrementAttribute = "AXValueIncrement" // New +public let kAXAllowedValuesAttribute = "AXAllowedValues" // New + +// Text-specific attributes +public let kAXSelectedTextAttribute = "AXSelectedText" // New +public let kAXSelectedTextRangeAttribute = "AXSelectedTextRange" // New +public let kAXNumberOfCharactersAttribute = "AXNumberOfCharacters" // New +public let kAXVisibleCharacterRangeAttribute = "AXVisibleCharacterRange" // New +public let kAXInsertionPointLineNumberAttribute = "AXInsertionPointLineNumber" // New + +// Actions - Values should match CFSTR defined in AXActionConstants.h +public let kAXActionsAttribute = "AXActions" // This is actually kAXActionNamesAttribute typically +public let kAXActionNamesAttribute = "AXActionNames" // Correct name for listing actions +public let kAXActionDescriptionAttribute = "AXActionDescription" // To get desc of an action (not in AXActionConstants.h but AXUIElement.h) + +public let kAXIncrementAction = "AXIncrement" // New +public let kAXDecrementAction = "AXDecrement" // New +public let kAXConfirmAction = "AXConfirm" // New +public let kAXCancelAction = "AXCancel" // New +public let kAXShowMenuAction = "AXShowMenu" +public let kAXPickAction = "AXPick" // New (Obsolete in headers, but sometimes seen) +public let kAXPressAction = "AXPress" // New + +// Specific action name for setting a value, used internally by performActionOnElement +public let kAXSetValueAction = "AXSetValue" + +// Standard Accessibility Roles - Values should match CFSTR defined in AXRoleConstants.h (examples, add more as needed) +public let kAXApplicationRole = "AXApplication" +public let kAXSystemWideRole = "AXSystemWide" // New +public let kAXWindowRole = "AXWindow" +public let kAXSheetRole = "AXSheet" // New +public let kAXDrawerRole = "AXDrawer" // New +public let kAXGroupRole = "AXGroup" +public let kAXButtonRole = "AXButton" +public let kAXRadioButtonRole = "AXRadioButton" // New +public let kAXCheckBoxRole = "AXCheckBox" +public let kAXPopUpButtonRole = "AXPopUpButton" // New +public let kAXMenuButtonRole = "AXMenuButton" // New +public let kAXStaticTextRole = "AXStaticText" +public let kAXTextFieldRole = "AXTextField" +public let kAXTextAreaRole = "AXTextArea" +public let kAXScrollAreaRole = "AXScrollArea" +public let kAXScrollBarRole = "AXScrollBar" // New +public let kAXWebAreaRole = "AXWebArea" +public let kAXImageRole = "AXImage" // New +public let kAXListRole = "AXList" // New +public let kAXTableRole = "AXTable" // New +public let kAXOutlineRole = "AXOutline" // New +public let kAXColumnRole = "AXColumn" // New +public let kAXRowRole = "AXRow" // New +public let kAXToolbarRole = "AXToolbar" +public let kAXBusyIndicatorRole = "AXBusyIndicator" // New +public let kAXProgressIndicatorRole = "AXProgressIndicator" // New +public let kAXSliderRole = "AXSlider" // New +public let kAXIncrementorRole = "AXIncrementor" // New +public let kAXDisclosureTriangleRole = "AXDisclosureTriangle" // New +public let kAXMenuRole = "AXMenu" // New +public let kAXMenuItemRole = "AXMenuItem" // New +public let kAXSplitGroupRole = "AXSplitGroup" // New +public let kAXSplitterRole = "AXSplitter" // New +public let kAXColorWellRole = "AXColorWell" // New +public let kAXUnknownRole = "AXUnknown" // New + +// Attributes for web content and tables/lists +public let kAXVisibleChildrenAttribute = "AXVisibleChildren" +public let kAXSelectedChildrenAttribute = "AXSelectedChildren" +public let kAXTabsAttribute = "AXTabs" // Often a kAXRadioGroup or kAXTabGroup role +public let kAXRowsAttribute = "AXRows" +public let kAXColumnsAttribute = "AXColumns" +public let kAXSelectedRowsAttribute = "AXSelectedRows" // New +public let kAXSelectedColumnsAttribute = "AXSelectedColumns" // New +public let kAXIndexAttribute = "AXIndex" // New (for rows/columns) +public let kAXDisclosingAttribute = "AXDisclosing" // New (for outlines) + +// Custom or less standard attributes (verify usage and standard names) +public let kAXPathHintAttribute = "AXPathHint" // Our custom attribute for pathing + +// String constant for "not available" +public let kAXNotAvailableString = "n/a" + +// DOM specific attributes (these seem custom or web-specific, not standard Apple AX) +// Verify if these are actual attribute names exposed by web views or custom implementations. +public let kAXDOMIdentifierAttribute = "AXDOMIdentifier" // Example, might not be standard AX +public let kAXDOMClassListAttribute = "AXDOMClassList" // Example, might not be standard AX +public let kAXARIADOMResourceAttribute = "AXARIADOMResource" // Example +public let kAXARIADOMFunctionAttribute = "AXARIADOM-funciĂ³n" // Corrected identifier, kept original string value. +public let kAXARIADOMChildrenAttribute = "AXARIADOMChildren" // New +public let kAXDOMChildrenAttribute = "AXDOMChildren" // New + +// New constants for missing attributes +public let kAXToolbarButtonAttribute = "AXToolbarButton" +public let kAXProxyAttribute = "AXProxy" +public let kAXSelectedCellsAttribute = "AXSelectedCells" +public let kAXHeaderAttribute = "AXHeader" +public let kAXHorizontalScrollBarAttribute = "AXHorizontalScrollBar" +public let kAXVerticalScrollBarAttribute = "AXVerticalScrollBar" + +// Attributes used in child heuristic collection (often non-standard or specific) +public let kAXWebAreaChildrenAttribute = "AXWebAreaChildren" +public let kAXHTMLContentAttribute = "AXHTMLContent" +public let kAXApplicationNavigationAttribute = "AXApplicationNavigation" +public let kAXApplicationElementsAttribute = "AXApplicationElements" +public let kAXContentsAttribute = "AXContents" +public let kAXBodyAreaAttribute = "AXBodyArea" +public let kAXDocumentContentAttribute = "AXDocumentContent" +public let kAXWebPageContentAttribute = "AXWebPageContent" +public let kAXSplitGroupContentsAttribute = "AXSplitGroupContents" +public let kAXLayoutAreaChildrenAttribute = "AXLayoutAreaChildren" +public let kAXGroupChildrenAttribute = "AXGroupChildren" + +// Helper function to convert AXError to a string +public func axErrorToString(_ error: AXError) -> String { + switch error { + case .success: return "success" + case .failure: return "failure" + case .apiDisabled: return "apiDisabled" + case .invalidUIElement: return "invalidUIElement" + case .invalidUIElementObserver: return "invalidUIElementObserver" + case .cannotComplete: return "cannotComplete" + case .attributeUnsupported: return "attributeUnsupported" + case .actionUnsupported: return "actionUnsupported" + case .notificationUnsupported: return "notificationUnsupported" + case .notImplemented: return "notImplemented" + case .notificationAlreadyRegistered: return "notificationAlreadyRegistered" + case .notificationNotRegistered: return "notificationNotRegistered" + case .noValue: return "noValue" + case .parameterizedAttributeUnsupported: return "parameterizedAttributeUnsupported" + case .notEnoughPrecision: return "notEnoughPrecision" + case .illegalArgument: return "illegalArgument" + @unknown default: + return "unknown AXError (code: \(error.rawValue))" + } +} + +// MARK: - Custom Application/Computed Keys + +public let focusedApplicationKey = "focused" +public let computedNameAttributeKey = "ComputedName" +public let isClickableAttributeKey = "IsClickable" +public let isIgnoredAttributeKey = "IsIgnored" // Used in AttributeMatcher +public let computedPathAttributeKey = "ComputedPath" \ No newline at end of file diff --git a/Sources/AXorcist/Core/AccessibilityError.swift b/Sources/AXorcist/Core/AccessibilityError.swift new file mode 100644 index 0000000..ad64c01 --- /dev/null +++ b/Sources/AXorcist/Core/AccessibilityError.swift @@ -0,0 +1,108 @@ +// AccessibilityError.swift - Defines custom error types for the accessibility tool. + +import Foundation +import ApplicationServices // Import to make AXError visible + +// Main error enum for the accessibility tool, incorporating parsing and operational errors. +public enum AccessibilityError: Error, CustomStringConvertible { + // Authorization & Setup Errors + case apiDisabled // Accessibility API is disabled. + case notAuthorized(String?) // Process is not authorized. Optional AXError for more detail. + + // Command & Input Errors + case invalidCommand(String?) // Command is invalid or not recognized. Optional message. + case missingArgument(String) // A required argument is missing. + case invalidArgument(String) // An argument has an invalid value or format. + + // Element & Search Errors + case appNotFound(String) // Application with specified bundle ID or name not found or not running. + case elementNotFound(String?) // Element matching criteria or path not found. Optional message. + case invalidElement // The AXUIElementRef is invalid or stale. + + // Attribute Errors + case attributeUnsupported(String) // Attribute is not supported by the element. + case attributeNotReadable(String) // Attribute value cannot be read. + case attributeNotSettable(String) // Attribute is not settable. + case typeMismatch(expected: String, actual: String) // Value type does not match attribute's expected type. + case valueParsingFailed(details: String) // Failed to parse string into the required type for an attribute. + case valueNotAXValue(String) // Value is not an AXValue type when one is expected. + + // Action Errors + case actionUnsupported(String) // Action is not supported by the element. + case actionFailed(String?, AXError?) // Action failed. Optional message and AXError. + + // Generic & System Errors + case unknownAXError(AXError) // An unknown or unexpected AXError occurred. + case jsonEncodingFailed(Error?) // Failed to encode response to JSON. + case jsonDecodingFailed(Error?) // Failed to decode request from JSON. + case genericError(String) // A generic error with a custom message. + + public var description: String { + switch self { + // Authorization & Setup + case .apiDisabled: return "Accessibility API is disabled. Please enable it in System Settings." + case .notAuthorized(let axErr): + let base = "Accessibility permissions are not granted for this process." + if let e = axErr { return "\(base) AXError: \(e)" } + return base + + // Command & Input + case .invalidCommand(let msg): + let base = "Invalid command specified." + if let m = msg { return "\(base) \(m)" } + return base + case .missingArgument(let name): return "Missing required argument: \(name)." + case .invalidArgument(let details): return "Invalid argument: \(details)." + + // Element & Search + case .appNotFound(let app): return "Application '\(app)' not found or not running." + case .elementNotFound(let msg): + let base = "No element matches the locator criteria or path." + if let m = msg { return "\(base) \(m)" } + return base + case .invalidElement: return "The specified UI element is invalid (possibly stale)." + + // Attribute Errors + case .attributeUnsupported(let attr): return "Attribute '\(attr)' is not supported by this element." + case .attributeNotReadable(let attr): return "Attribute '\(attr)' is not readable." + case .attributeNotSettable(let attr): return "Attribute '\(attr)' is not settable." + case .typeMismatch(let expected, let actual): return "Type mismatch: Expected '\(expected)', got '\(actual)'." + case .valueParsingFailed(let details): return "Value parsing failed: \(details)." + case .valueNotAXValue(let attr): return "Value for attribute '\(attr)' is not an AXValue type as expected." + + // Action Errors + case .actionUnsupported(let action): return "Action '\(action)' is not supported by this element." + case .actionFailed(let msg, let axErr): + var parts: [String] = ["Action failed."] + if let m = msg { parts.append(m) } + if let e = axErr { parts.append("AXError: \(e).") } + return parts.joined(separator: " ") + + // Generic & System + case .unknownAXError(let e): return "An unexpected Accessibility Framework error occurred: \(e)." + case .jsonEncodingFailed(let err): + let base = "Failed to encode the response to JSON." + if let e = err { return "\(base) Error: \(e.localizedDescription)" } + return base + case .jsonDecodingFailed(let err): + let base = "Failed to decode the JSON command input." + if let e = err { return "\(base) Error: \(e.localizedDescription)" } + return base + case .genericError(let msg): return msg + } + } + + // Helper to get a more specific exit code if needed, or a general one. + // This is just an example; actual exit codes might vary. + public var exitCode: Int32 { + switch self { + case .apiDisabled, .notAuthorized: return 10 + case .invalidCommand, .missingArgument, .invalidArgument: return 20 + case .appNotFound, .elementNotFound, .invalidElement: return 30 + case .attributeUnsupported, .attributeNotReadable, .attributeNotSettable, .typeMismatch, .valueParsingFailed, .valueNotAXValue: return 40 + case .actionUnsupported, .actionFailed: return 50 + case .jsonEncodingFailed, .jsonDecodingFailed: return 60 + case .unknownAXError, .genericError: return 1 + } + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/AccessibilityPermissions.swift b/Sources/AXorcist/Core/AccessibilityPermissions.swift new file mode 100644 index 0000000..6d816bf --- /dev/null +++ b/Sources/AXorcist/Core/AccessibilityPermissions.swift @@ -0,0 +1,118 @@ +// AccessibilityPermissions.swift - Utility for checking and managing accessibility permissions. + +import Foundation +import ApplicationServices // For AXIsProcessTrusted(), AXUIElementCreateSystemWide(), etc. +import AppKit // For NSRunningApplication, NSAppleScript + +private let kAXTrustedCheckOptionPromptKey = "AXTrustedCheckOptionPrompt" + +// debug() is assumed to be globally available from Logging.swift +// getParentProcessName() is assumed to be globally available from ProcessUtils.swift +// kAXFocusedUIElementAttribute is assumed to be globally available from AccessibilityConstants.swift +// AccessibilityError is from AccessibilityError.swift + +public struct AXPermissionsStatus { + public let isAccessibilityApiEnabled: Bool + public let isProcessTrustedForAccessibility: Bool + public var automationStatus: [String: Bool] = [:] // BundleID: Bool (true if permitted, false if denied, nil if not checked or app not running) + public var overallErrorMessages: [String] = [] + + public var canUseAccessibility: Bool { + isAccessibilityApiEnabled && isProcessTrustedForAccessibility + } + + public func canAutomate(bundleID: String) -> Bool? { + return automationStatus[bundleID] + } +} + +@MainActor +public func checkAccessibilityPermissions(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) throws { + // Define local dLog using passed-in parameters + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + + let trustedOptions = [kAXTrustedCheckOptionPromptKey: true] as CFDictionary + // tempLogs is already declared for getParentProcessName, which is good. + // var tempLogs: [String] = [] // This would be a re-declaration error if uncommented + + if !AXIsProcessTrustedWithOptions(trustedOptions) { + // Use isDebugLoggingEnabled for the call to getParentProcessName + let parentName = getParentProcessName(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + let errorDetail = parentName != nil ? "Hint: Grant accessibility permissions to '\(parentName!)'." : "Hint: Ensure the application running this tool has Accessibility permissions." + dLog("Accessibility check failed (AXIsProcessTrustedWithOptions returned false). Details: \(errorDetail)") + throw AccessibilityError.notAuthorized(errorDetail) + } else { + dLog("Accessibility permissions are granted (AXIsProcessTrustedWithOptions returned true).") + } +} + +// @MainActor // Removed again for pragmatic stability +public func getPermissionsStatus(checkAutomationFor bundleIDs: [String] = [], isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> AXPermissionsStatus { + // Local dLog appends to currentDebugLogs + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + + dLog("Starting full permission status check.") + + // Check overall accessibility API status and process trust + let isProcessTrusted = AXIsProcessTrusted() // Non-prompting check + // let isApiEnabled = AXAPIEnabled() // System-wide check, REMOVED due to unavailability + + if isDebugLoggingEnabled { + dLog("AXIsProcessTrusted() returned: \(isProcessTrusted)") + // dLog("AXAPIEnabled() returned: \(isApiEnabled) (Note: AXAPIEnabled is deprecated)") // Removed + if !isProcessTrusted { + let parentName = getParentProcessName(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + let hint = parentName != nil ? "Hint: Grant accessibility permissions to '\(parentName!)'." : "Hint: Ensure the application running this tool has Accessibility permissions." + currentDebugLogs.append("Process is not trusted for Accessibility. \(hint)") + } + // Removed isApiEnabled check block + } + + var automationStatus: [String: Bool] = [:] + + if !bundleIDs.isEmpty && isProcessTrusted { // Only check automation if basic permissions seem okay (removed isApiEnabled from condition) + if isDebugLoggingEnabled { dLog("Checking automation permissions for bundle IDs: \(bundleIDs.joined(separator: ", "))") } + for bundleID in bundleIDs { + if NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first != nil { // Changed from if let app = ... + let scriptSource = """ + tell application id \"\(bundleID)\" to count windows + """ + var errorDict: NSDictionary? = nil + if let script = NSAppleScript(source: scriptSource) { + if isDebugLoggingEnabled { dLog("Executing AppleScript against \(bundleID) to check automation status.") } + let descriptor = script.executeAndReturnError(&errorDict) // descriptor is non-optional + + if errorDict == nil && descriptor.descriptorType != typeNull { + // No error dictionary populated and descriptor is not typeNull, assume success for permissions. + automationStatus[bundleID] = true + if isDebugLoggingEnabled { dLog("AppleScript execution against \(bundleID) succeeded (no errorDict, descriptor type: \(descriptor.descriptorType.description)). Automation permitted.") } + } else { + automationStatus[bundleID] = false + if isDebugLoggingEnabled { + let errorCode = errorDict?[NSAppleScript.errorNumber] as? Int ?? 0 + let errorMessage = errorDict?[NSAppleScript.errorMessage] as? String ?? "Unknown AppleScript error" + let descriptorDetails = errorDict == nil ? "Descriptor was typeNull (type: \(descriptor.descriptorType.description)) but no errorDict." : "" + currentDebugLogs.append("AppleScript execution against \(bundleID) failed. Automation likely denied. Code: \(errorCode), Msg: \(errorMessage). \(descriptorDetails)") + } + } + } else { + if isDebugLoggingEnabled { currentDebugLogs.append("Could not initialize AppleScript for bundle ID '\(bundleID)'.") } + } + } else { + if isDebugLoggingEnabled { currentDebugLogs.append("Application with bundle ID '\(bundleID)' is not running. Cannot check automation status.") } + // automationStatus[bundleID] remains nil (not checked) + } + } + } else if !bundleIDs.isEmpty { + if isDebugLoggingEnabled { dLog("Skipping automation permission checks because basic accessibility (isProcessTrusted: \(isProcessTrusted)) is not met.") } + } + + let finalStatus = AXPermissionsStatus( + isAccessibilityApiEnabled: isProcessTrusted, // Base this on isProcessTrusted now + isProcessTrustedForAccessibility: isProcessTrusted, + automationStatus: automationStatus, + overallErrorMessages: currentDebugLogs // All logs collected so far become the messages + ) + dLog("Finished permission status check. isAccessibilityApiEnabled: \(finalStatus.isAccessibilityApiEnabled), isProcessTrusted: \(finalStatus.isProcessTrustedForAccessibility)") + return finalStatus +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/Attribute.swift b/Sources/AXorcist/Core/Attribute.swift new file mode 100644 index 0000000..31cace7 --- /dev/null +++ b/Sources/AXorcist/Core/Attribute.swift @@ -0,0 +1,113 @@ +// Attribute.swift - Defines a typed wrapper for Accessibility Attribute keys. + +import Foundation +import ApplicationServices // Re-add for AXUIElement type +// import ApplicationServices // For kAX... constants - We will now use AccessibilityConstants.swift primarily +import CoreGraphics // For CGRect, CGPoint, CGSize, CFRange + +// A struct to provide a type-safe way to refer to accessibility attributes. +// The generic type T represents the expected Swift type of the attribute's value. +// Note: For attributes returning AXValue (like CGPoint, CGRect), T might be the AXValue itself +// or the final unwrapped Swift type. For now, let's aim for the final Swift type where possible. +public struct Attribute { + public let rawValue: String + + // Internal initializer to allow creation within the module, e.g., for dynamic attribute strings. + internal init(_ rawValue: String) { + self.rawValue = rawValue + } + + // MARK: - General Element Attributes + public static var role: Attribute { Attribute(kAXRoleAttribute) } + public static var subrole: Attribute { Attribute(kAXSubroleAttribute) } + public static var roleDescription: Attribute { Attribute(kAXRoleDescriptionAttribute) } + public static var title: Attribute { Attribute(kAXTitleAttribute) } + public static var description: Attribute { Attribute(kAXDescriptionAttribute) } + public static var help: Attribute { Attribute(kAXHelpAttribute) } + public static var identifier: Attribute { Attribute(kAXIdentifierAttribute) } + + // MARK: - Value Attributes + // kAXValueAttribute can be many types. For a generic getter, Any might be appropriate, + // or specific versions if the context knows the type. + public static var value: Attribute { Attribute(kAXValueAttribute) } + // Example of a more specific value if known: + // static var stringValue: Attribute { Attribute(kAXValueAttribute) } + + // MARK: - State Attributes + public static var enabled: Attribute { Attribute(kAXEnabledAttribute) } + public static var focused: Attribute { Attribute(kAXFocusedAttribute) } + public static var busy: Attribute { Attribute(kAXElementBusyAttribute) } + public static var hidden: Attribute { Attribute(kAXHiddenAttribute) } + + // MARK: - Hierarchy Attributes + public static var parent: Attribute { Attribute(kAXParentAttribute) } + // For children, the direct attribute often returns [AXUIElement]. + // Element.children getter then wraps these. + public static var children: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXChildrenAttribute) } + public static var selectedChildren: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXSelectedChildrenAttribute) } + public static var visibleChildren: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXVisibleChildrenAttribute) } + public static var windows: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXWindowsAttribute) } + public static var mainWindow: Attribute { Attribute(kAXMainWindowAttribute) } // Can be nil + public static var focusedWindow: Attribute { Attribute(kAXFocusedWindowAttribute) } // Can be nil + public static var focusedElement: Attribute { Attribute(kAXFocusedUIElementAttribute) } // Can be nil + + // MARK: - Application Specific Attributes + // public static var enhancedUserInterface: Attribute { Attribute(kAXEnhancedUserInterfaceAttribute) } // Constant not found, commenting out + public static var frontmost: Attribute { Attribute(kAXFrontmostAttribute) } + public static var mainMenu: Attribute { Attribute(kAXMenuBarAttribute) } + // public static var hiddenApplication: Attribute { Attribute(kAXHiddenAttribute) } // Same as element hidden, but for app. Covered by .hidden + + // MARK: - Window Specific Attributes + public static var minimized: Attribute { Attribute(kAXMinimizedAttribute) } + public static var modal: Attribute { Attribute(kAXModalAttribute) } + public static var defaultButton: Attribute { Attribute(kAXDefaultButtonAttribute) } + public static var cancelButton: Attribute { Attribute(kAXCancelButtonAttribute) } + public static var closeButton: Attribute { Attribute(kAXCloseButtonAttribute) } + public static var zoomButton: Attribute { Attribute(kAXZoomButtonAttribute) } + public static var minimizeButton: Attribute { Attribute(kAXMinimizeButtonAttribute) } + public static var toolbarButton: Attribute { Attribute(kAXToolbarButtonAttribute) } + public static var fullScreenButton: Attribute { Attribute(kAXFullScreenButtonAttribute) } + public static var proxy: Attribute { Attribute(kAXProxyAttribute) } + public static var growArea: Attribute { Attribute(kAXGrowAreaAttribute) } + + // MARK: - Table/List/Outline Attributes + public static var rows: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXRowsAttribute) } + public static var columns: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXColumnsAttribute) } + public static var selectedRows: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXSelectedRowsAttribute) } + public static var selectedColumns: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXSelectedColumnsAttribute) } + public static var selectedCells: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXSelectedCellsAttribute) } + public static var visibleRows: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXVisibleRowsAttribute) } + public static var visibleColumns: Attribute<[AXUIElement]> { Attribute<[AXUIElement]>(kAXVisibleColumnsAttribute) } + public static var header: Attribute { Attribute(kAXHeaderAttribute) } + public static var orientation: Attribute { Attribute(kAXOrientationAttribute) } // e.g., kAXVerticalOrientationValue + + // MARK: - Text Attributes + public static var selectedText: Attribute { Attribute(kAXSelectedTextAttribute) } + public static var selectedTextRange: Attribute { Attribute(kAXSelectedTextRangeAttribute) } + public static var numberOfCharacters: Attribute { Attribute(kAXNumberOfCharactersAttribute) } + public static var visibleCharacterRange: Attribute { Attribute(kAXVisibleCharacterRangeAttribute) } + // Parameterized attributes are handled differently, often via functions. + // static var attributedStringForRange: Attribute { Attribute(kAXAttributedStringForRangeParameterizedAttribute) } + // static var stringForRange: Attribute { Attribute(kAXStringForRangeParameterizedAttribute) } + + // MARK: - Scroll Area Attributes + public static var horizontalScrollBar: Attribute { Attribute(kAXHorizontalScrollBarAttribute) } + public static var verticalScrollBar: Attribute { Attribute(kAXVerticalScrollBarAttribute) } + + // MARK: - Action Related + // Action names are typically an array of strings. + public static var actionNames: Attribute<[String]> { Attribute<[String]>(kAXActionNamesAttribute) } + // Action description is parameterized by the action name, so a simple Attribute isn't quite right. + // It would be kAXActionDescriptionAttribute, and you pass a parameter. + // For now, we will represent it as taking a string, and the usage site will need to handle parameterization. + public static var actionDescription: Attribute { Attribute(kAXActionDescriptionAttribute) } + + // MARK: - AXValue holding attributes (expect these to return AXValueRef) + // These will typically be unwrapped by a helper function (like ValueParser or similar) into their Swift types. + public static var position: Attribute { Attribute(kAXPositionAttribute) } + public static var size: Attribute { Attribute(kAXSizeAttribute) } + // Note: CGRect for kAXBoundsAttribute is also common if available. + // For now, relying on position and size. + + // Add more attributes as needed from ApplicationServices/HIServices Accessibility Attributes... +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/Element+Hierarchy.swift b/Sources/AXorcist/Core/Element+Hierarchy.swift new file mode 100644 index 0000000..3679eae --- /dev/null +++ b/Sources/AXorcist/Core/Element+Hierarchy.swift @@ -0,0 +1,87 @@ +import Foundation +import ApplicationServices + +// MARK: - Element Hierarchy Logic + +extension Element { + @MainActor + public func children(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> [Element]? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var collectedChildren: [Element] = [] + var uniqueChildrenSet = Set() + var tempLogs: [String] = [] // For inner calls + + dLog("Getting children for element: \(self.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs))") + + // Primary children attribute + tempLogs.removeAll() + if let directChildrenUI: [AXUIElement] = attribute(Attribute<[AXUIElement]>.children, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) { + currentDebugLogs.append(contentsOf: tempLogs) + for childUI in directChildrenUI { + let childAX = Element(childUI) + if !uniqueChildrenSet.contains(childAX) { + collectedChildren.append(childAX) + uniqueChildrenSet.insert(childAX) + } + } + } else { + currentDebugLogs.append(contentsOf: tempLogs) // Append logs even if nil + } + + // Alternative children attributes + let alternativeAttributes: [String] = [ + kAXVisibleChildrenAttribute, kAXWebAreaChildrenAttribute, kAXHTMLContentAttribute, + kAXARIADOMChildrenAttribute, kAXDOMChildrenAttribute, kAXApplicationNavigationAttribute, + kAXApplicationElementsAttribute, kAXContentsAttribute, kAXBodyAreaAttribute, kAXDocumentContentAttribute, + kAXWebPageContentAttribute, kAXSplitGroupContentsAttribute, kAXLayoutAreaChildrenAttribute, + kAXGroupChildrenAttribute, kAXSelectedChildrenAttribute, kAXRowsAttribute, kAXColumnsAttribute, + kAXTabsAttribute + ] + + for attrName in alternativeAttributes { + tempLogs.removeAll() + if let altChildrenUI: [AXUIElement] = attribute(Attribute<[AXUIElement]>(attrName), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) { + currentDebugLogs.append(contentsOf: tempLogs) + for childUI in altChildrenUI { + let childAX = Element(childUI) + if !uniqueChildrenSet.contains(childAX) { + collectedChildren.append(childAX) + uniqueChildrenSet.insert(childAX) + } + } + } else { + currentDebugLogs.append(contentsOf: tempLogs) + } + } + + tempLogs.removeAll() + let currentRole = self.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs) + + if currentRole == kAXApplicationRole as String { + tempLogs.removeAll() + if let windowElementsUI: [AXUIElement] = attribute(Attribute<[AXUIElement]>.windows, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) { + currentDebugLogs.append(contentsOf: tempLogs) + for childUI in windowElementsUI { + let childAX = Element(childUI) + if !uniqueChildrenSet.contains(childAX) { + collectedChildren.append(childAX) + uniqueChildrenSet.insert(childAX) + } + } + } else { + currentDebugLogs.append(contentsOf: tempLogs) + } + } + + if collectedChildren.isEmpty { + dLog("No children found for element.") + return nil + } else { + dLog("Found \(collectedChildren.count) children.") + return collectedChildren + } + } + + // generatePathString() is now fully implemented in Element.swift +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/Element+Properties.swift b/Sources/AXorcist/Core/Element+Properties.swift new file mode 100644 index 0000000..8118aaa --- /dev/null +++ b/Sources/AXorcist/Core/Element+Properties.swift @@ -0,0 +1,98 @@ +import Foundation +import ApplicationServices + +// MARK: - Element Common Attribute Getters & Status Properties + +extension Element { + // Common Attribute Getters - now methods to accept logging parameters + @MainActor public func role(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + attribute(Attribute.role, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func subrole(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + attribute(Attribute.subrole, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func title(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + attribute(Attribute.title, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func description(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + attribute(Attribute.description, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func isEnabled(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool? { + attribute(Attribute.enabled, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func value(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Any? { + attribute(Attribute.value, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func roleDescription(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + attribute(Attribute.roleDescription, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func help(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + attribute(Attribute.help, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func identifier(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + attribute(Attribute.identifier, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + + // Status Properties - now methods + @MainActor public func isFocused(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool? { + attribute(Attribute.focused, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func isHidden(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool? { + attribute(Attribute.hidden, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + @MainActor public func isElementBusy(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool? { + attribute(Attribute.busy, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + + @MainActor public func isIgnored(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool { + if attribute(Attribute.hidden, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) == true { + return true + } + return false + } + + @MainActor public func pid(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> pid_t? { + // This function doesn't call self.attribute, so its logging is self-contained if any. + // For now, assuming AXUIElementGetPid doesn't log through our system. + // If verbose logging of this specific call is needed, add dLog here. + var processID: pid_t = 0 + let error = AXUIElementGetPid(self.underlyingElement, &processID) + if error == .success { + return processID + } + // Optional: dLog if error and isDebugLoggingEnabled + return nil + } + + // Hierarchy and Relationship Getters - now methods + @MainActor public func parent(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Element? { + guard let parentElementUI: AXUIElement = attribute(Attribute.parent, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { return nil } + return Element(parentElementUI) + } + + @MainActor public func windows(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> [Element]? { + guard let windowElementsUI: [AXUIElement] = attribute(Attribute<[AXUIElement]>.windows, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { return nil } + return windowElementsUI.map { Element($0) } + } + + @MainActor public func mainWindow(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Element? { + guard let windowElementUI: AXUIElement = attribute(Attribute.mainWindow, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? nil else { return nil } + return Element(windowElementUI) + } + + @MainActor public func focusedWindow(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Element? { + guard let windowElementUI: AXUIElement = attribute(Attribute.focusedWindow, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? nil else { return nil } + return Element(windowElementUI) + } + + @MainActor public func focusedElement(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Element? { + guard let elementUI: AXUIElement = attribute(Attribute.focusedElement, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? nil else { return nil } + return Element(elementUI) + } + + // Action-related - now a method + @MainActor + public func supportedActions(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> [String]? { + return attribute(Attribute<[String]>.actionNames, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/Element.swift b/Sources/AXorcist/Core/Element.swift new file mode 100644 index 0000000..fd39c70 --- /dev/null +++ b/Sources/AXorcist/Core/Element.swift @@ -0,0 +1,355 @@ +// Element.swift - Wrapper for AXUIElement for a more Swift-idiomatic interface + +import Foundation +import ApplicationServices // For AXUIElement and other C APIs +// We might need to import ValueHelpers or other local modules later + +// Element struct is NOT @MainActor. Isolation is applied to members that need it. +public struct Element: Equatable, Hashable { + public let underlyingElement: AXUIElement + + public init(_ element: AXUIElement) { + self.underlyingElement = element + } + + // Implement Equatable - no longer needs nonisolated as struct is not @MainActor + public static func == (lhs: Element, rhs: Element) -> Bool { + return CFEqual(lhs.underlyingElement, rhs.underlyingElement) + } + + // Implement Hashable - no longer needs nonisolated + public func hash(into hasher: inout Hasher) { + hasher.combine(CFHash(underlyingElement)) + } + + // Generic method to get an attribute's value (converted to Swift type T) + @MainActor + public func attribute(_ attribute: Attribute, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> T? { + // axValue is from ValueHelpers.swift and now expects logging parameters + return axValue(of: self.underlyingElement, attr: attribute.rawValue, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) as T? + } + + // Method to get the raw CFTypeRef? for an attribute + // This is useful for functions like attributesMatch that do their own CFTypeID checking. + // This also needs to be @MainActor as AXUIElementCopyAttributeValue should be on main thread. + @MainActor + public func rawAttributeValue(named attributeName: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> CFTypeRef? { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(self.underlyingElement, attributeName as CFString, &value) + if error == .success { + return value // Caller is responsible for CFRelease if it's a new object they own. + // For many get operations, this is a copy-get rule, but some are direct gets. + // Since we just return it, the caller should be aware or this function should manage it. + // Given AXSwift patterns, often the raw value isn't directly exposed like this, + // or it is clearly documented. For now, let's assume this is for internal use by attributesMatch + // which previously used copyAttributeValue which likely returned a +1 ref count object. + } else if error == .attributeUnsupported { + dLog("rawAttributeValue: Attribute \(attributeName) unsupported for element \(self.underlyingElement)") + } else if error == .noValue { + dLog("rawAttributeValue: Attribute \(attributeName) has no value for element \(self.underlyingElement)") + } else { + dLog("rawAttributeValue: Error getting attribute \(attributeName) for element \(self.underlyingElement): \(error.rawValue)") + } + return nil // Return nil if not success or if value was nil (though success should mean value is populated) + } + + // MARK: - Common Attribute Getters (MOVED to Element+Properties.swift) + // MARK: - Status Properties (MOVED to Element+Properties.swift) + // MARK: - Hierarchy and Relationship Getters (Simpler ones MOVED to Element+Properties.swift) + // MARK: - Action-related (supportedActions MOVED to Element+Properties.swift) + + // Remaining properties and methods will stay here for now + // (e.g., children, isActionSupported, performAction, parameterizedAttribute, briefDescription, generatePathString, static factories) + + // MOVED to Element+Hierarchy.swift + // @MainActor public var children: [Element]? { ... } + + // MARK: - Actions (supportedActions moved, other action methods remain) + + @MainActor + public func isActionSupported(_ actionName: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool { + if let actions: [String] = attribute(Attribute<[String]>.actionNames, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + return actions.contains(actionName) + } + return false + } + + @MainActor + @discardableResult + public func performAction(_ actionName: Attribute, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) throws -> Element { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + let error = AXUIElementPerformAction(self.underlyingElement, actionName.rawValue as CFString) + if error != .success { + // Now call the refactored briefDescription, passing the logs along. + let desc = self.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + dLog("Action \(actionName.rawValue) failed on element \(desc). Error: \(error.rawValue)") + throw AccessibilityError.actionFailed("Action \(actionName.rawValue) failed on element \(desc)", error) + } + return self + } + + @MainActor + @discardableResult + public func performAction(_ actionName: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) throws -> Element { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + let error = AXUIElementPerformAction(self.underlyingElement, actionName as CFString) + if error != .success { + // Now call the refactored briefDescription, passing the logs along. + let desc = self.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + dLog("Action \(actionName) failed on element \(desc). Error: \(error.rawValue)") + throw AccessibilityError.actionFailed("Action \(actionName) failed on element \(desc)", error) + } + return self + } + + // MARK: - Parameterized Attributes + + @MainActor + public func parameterizedAttribute(_ attribute: Attribute, forParameter parameter: Any, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> T? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var cfParameter: CFTypeRef? + + // Convert Swift parameter to CFTypeRef for the API + if var range = parameter as? CFRange { + cfParameter = AXValueCreate(.cfRange, &range) + } else if let string = parameter as? String { + cfParameter = string as CFString + } else if let number = parameter as? NSNumber { + cfParameter = number + } else if CFGetTypeID(parameter as CFTypeRef) != 0 { // Check if it's already a CFTypeRef-compatible type + cfParameter = (parameter as CFTypeRef) + } else { + dLog("parameterizedAttribute: Unsupported parameter type \(type(of: parameter))") + return nil + } + + guard let actualCFParameter = cfParameter else { + dLog("parameterizedAttribute: Failed to convert parameter to CFTypeRef.") + return nil + } + + var value: CFTypeRef? + let error = AXUIElementCopyParameterizedAttributeValue(underlyingElement, attribute.rawValue as CFString, actualCFParameter, &value) + + if error != .success { + dLog("parameterizedAttribute: Error \(error.rawValue) getting attribute \(attribute.rawValue)") + return nil + } + + guard let resultCFValue = value else { return nil } + + // Use axValue's unwrapping and casting logic if possible, by temporarily creating an element and attribute + // This is a bit of a conceptual stretch, as axValue is designed for direct attributes. + // A more direct unwrap using ValueUnwrapper might be cleaner here. + let unwrappedValue = ValueUnwrapper.unwrap(resultCFValue, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + + guard let finalValue = unwrappedValue else { return nil } + + // Perform type casting similar to axValue + if T.self == String.self { + if let str = finalValue as? String { return str as? T } + else if let attrStr = finalValue as? NSAttributedString { return attrStr.string as? T } + return nil + } + if let castedValue = finalValue as? T { + return castedValue + } + dLog("parameterizedAttribute: Fallback cast attempt for attribute '\(attribute.rawValue)' to type \(T.self) FAILED. Unwrapped value was \(type(of: finalValue)): \(finalValue)") + return nil + } + + // MOVED to Element+Hierarchy.swift + // @MainActor + // public func generatePathString() -> String { ... } + + // MARK: - Attribute Accessors (Raw and Typed) + + // ... existing attribute accessors ... + + // MARK: - Computed Properties for Common Attributes & Heuristics + + // ... existing properties like role, title, isEnabled ... + + /// A computed name for the element, derived from common attributes like title, value, description, etc. + /// This provides a general-purpose, human-readable name. + @MainActor + // Convert from a computed property to a method to accept logging parameters + public func computedName(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + // Now uses the passed-in logging parameters for its internal calls + if let titleStr = self.title(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !titleStr.isEmpty, titleStr != kAXNotAvailableString { return titleStr } + + if let valueStr: String = self.value(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) as? String, !valueStr.isEmpty, valueStr != kAXNotAvailableString { return valueStr } + + if let descStr = self.description(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !descStr.isEmpty, descStr != kAXNotAvailableString { return descStr } + + if let helpStr: String = self.attribute(Attribute(kAXHelpAttribute), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !helpStr.isEmpty, helpStr != kAXNotAvailableString { return helpStr } + if let phValueStr: String = self.attribute(Attribute(kAXPlaceholderValueAttribute), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !phValueStr.isEmpty, phValueStr != kAXNotAvailableString { return phValueStr } + + let roleNameStr: String = self.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? "Element" + + if let roleDescStr: String = self.roleDescription(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !roleDescStr.isEmpty, roleDescStr != kAXNotAvailableString { + return "\(roleDescStr) (\(roleNameStr))" + } + return nil + } + + // MARK: - Path and Hierarchy +} + +// Convenience factory for the application element - already @MainActor +@MainActor +public func applicationElement(for bundleIdOrName: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Element? { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + // Now call pid() with logging parameters + guard let pid = pid(forAppIdentifier: bundleIdOrName, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + // dLog for "Failed to find PID..." is now handled inside pid() itself or if it returns nil here, we can log the higher level failure. + // The message below is slightly redundant if pid() logs its own failure, but can be useful. + dLog("applicationElement: Failed to obtain PID for '\(bundleIdOrName)'. Check previous logs from pid().") + return nil + } + let appElement = AXUIElementCreateApplication(pid) + return Element(appElement) +} + +// Convenience factory for the system-wide element - already @MainActor +@MainActor +public func systemWideElement(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Element { + // This function doesn't do much logging itself, but consistent signature is good. + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + dLog("Creating system-wide element.") + return Element(AXUIElementCreateSystemWide()) +} + +// Extension to generate a descriptive path string +extension Element { + @MainActor + // Update signature to include logging parameters + public func generatePathString(upTo ancestor: Element? = nil, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var pathComponents: [String] = [] + var currentElement: Element? = self + + var depth = 0 // Safety break for very deep or circular hierarchies + let maxDepth = 25 + var tempLogs: [String] = [] // Temporary logs for calls within the loop + + dLog("generatePathString started for element: \(self.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs)) upTo: \(ancestor?.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? "nil")") + + while let element = currentElement, depth < maxDepth { + tempLogs.removeAll() // Clear for each iteration + let briefDesc = element.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + pathComponents.append(briefDesc) + currentDebugLogs.append(contentsOf: tempLogs) // Append logs from briefDescription + + if let ancestor = ancestor, element == ancestor { + dLog("generatePathString: Reached specified ancestor: \(briefDesc)") + break // Reached the specified ancestor + } + + // Check role to prevent going above application or a window if its parent is the app + tempLogs.removeAll() + let role = element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs) + + tempLogs.removeAll() + let parentElement = element.parent(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs) + + tempLogs.removeAll() + let parentRole = parentElement?.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs) + + if role == kAXApplicationRole || (role == kAXWindowRole && parentRole == kAXApplicationRole && ancestor == nil) { + dLog("generatePathString: Stopping at \(role == kAXApplicationRole ? "Application" : "Window under App"): \(briefDesc)") + break + } + + currentElement = parentElement + depth += 1 + if currentElement == nil && role != kAXApplicationRole { + let orphanLog = "< Orphaned element path component: \(briefDesc) (role: \(role ?? "nil")) >" + dLog("generatePathString: Unexpected orphan: \(orphanLog)") + pathComponents.append(orphanLog) + break + } + } + if depth >= maxDepth { + dLog("generatePathString: Reached max depth (\(maxDepth)). Path might be truncated.") + pathComponents.append("<...max_depth_reached...>") + } + + let finalPath = pathComponents.reversed().joined(separator: " -> ") + dLog("generatePathString finished. Path: \(finalPath)") + return finalPath + } + + // New function to return path components as an array + @MainActor + public func generatePathArray(upTo ancestor: Element? = nil, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> [String] { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var pathComponents: [String] = [] + var currentElement: Element? = self + + var depth = 0 + let maxDepth = 25 + var tempLogs: [String] = [] + + dLog("generatePathArray started for element: \(self.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs)) upTo: \(ancestor?.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? "nil")") + currentDebugLogs.append(contentsOf: tempLogs); tempLogs.removeAll() + + while let element = currentElement, depth < maxDepth { + tempLogs.removeAll() + let briefDesc = element.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + pathComponents.append(briefDesc) + currentDebugLogs.append(contentsOf: tempLogs); tempLogs.removeAll() + + if let ancestor = ancestor, element == ancestor { + dLog("generatePathArray: Reached specified ancestor: \(briefDesc)") + break + } + + tempLogs.removeAll() + let role = element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs); tempLogs.removeAll() + + tempLogs.removeAll() + let parentElement = element.parent(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs); tempLogs.removeAll() + + tempLogs.removeAll() + let parentRole = parentElement?.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs); tempLogs.removeAll() + + if role == kAXApplicationRole || (role == kAXWindowRole && parentRole == kAXApplicationRole && ancestor == nil) { + dLog("generatePathArray: Stopping at \(role == kAXApplicationRole ? "Application" : "Window under App"): \(briefDesc)") + break + } + + currentElement = parentElement + depth += 1 + if currentElement == nil && role != kAXApplicationRole { + let orphanLog = "< Orphaned element path component: \(briefDesc) (role: \(role ?? "nil")) >" + dLog("generatePathArray: Unexpected orphan: \(orphanLog)") + pathComponents.append(orphanLog) + break + } + } + if depth >= maxDepth { + dLog("generatePathArray: Reached max depth (\(maxDepth)). Path might be truncated.") + pathComponents.append("<...max_depth_reached...>") + } + + let reversedPathComponents = Array(pathComponents.reversed()) + dLog("generatePathArray finished. Path components: \(reversedPathComponents.joined(separator: "/"))") // Log for debugging + return reversedPathComponents + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/Models.swift b/Sources/AXorcist/Core/Models.swift new file mode 100644 index 0000000..1f37954 --- /dev/null +++ b/Sources/AXorcist/Core/Models.swift @@ -0,0 +1,305 @@ +// Models.swift - Contains Codable structs for command handling and responses + +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 text_content // Primarily extracts textual content + case json_string // Returns the attributes as a JSON string (new) +} + +// Define CommandType enum +public enum CommandType: String, Codable { + case query + case performAction = "performAction" + case getAttributes = "getAttributes" + case batch + case describeElement = "describeElement" + case getFocusedElement = "getFocusedElement" + case collectAll = "collectAll" + case extractText = "extractText" + case ping + // Add future commands here, ensuring case matches JSON or provide explicit raw value +} + +// For encoding/decoding 'Any' type in JSON, especially for element attributes. +public struct AnyCodable: Codable { + public let value: Any + + public init(_ value: T?) { + self.value = value ?? () + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.value = () + } else if let bool = try? container.decode(Bool.self) { + self.value = bool + } else if let int = try? container.decode(Int.self) { + self.value = int + } else if let int32 = try? container.decode(Int32.self) { + self.value = int32 + } else if let int64 = try? container.decode(Int64.self) { + self.value = int64 + } else if let uint = try? container.decode(UInt.self) { + self.value = uint + } else if let uint32 = try? container.decode(UInt32.self) { + self.value = uint32 + } else if let uint64 = try? container.decode(UInt64.self) { + self.value = uint64 + } else if let double = try? container.decode(Double.self) { + self.value = double + } else if let float = try? container.decode(Float.self) { + self.value = float + } 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() + switch value { + case is Void: + try container.encodeNil() + 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 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: + let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded") + throw EncodingError.invalidValue(value, context) + } + } +} + +// Type alias for element attributes dictionary +public typealias ElementAttributes = [String: AnyCodable] + +// Main command envelope - REPLACED with definition from axorc.swift for consistency +public struct CommandEnvelope: Codable { + public let command_id: String + public let command: CommandType // Uses CommandType from this file + public let application: String? + public let attributes: [String]? + public let payload: [String: String]? // For ping compatibility + public let debug_logging: Bool? + public let locator: Locator? // Locator from this file + public let path_hint: [String]? + public let max_elements: Int? + public let output_format: OutputFormat? // OutputFormat from this file + public let action_name: String? // For performAction + public let action_value: AnyCodable? // For performAction (AnyCodable from this file) + public let sub_commands: [CommandEnvelope]? // For batch command + + // Added a public initializer for convenience, matching fields. + public init(command_id: String, + command: CommandType, + application: String? = nil, + attributes: [String]? = nil, + payload: [String : String]? = nil, + debug_logging: Bool? = nil, + locator: Locator? = nil, + path_hint: [String]? = nil, + max_elements: Int? = nil, + output_format: OutputFormat? = nil, + action_name: String? = nil, + action_value: AnyCodable? = nil, + sub_commands: [CommandEnvelope]? = nil + ) { + self.command_id = command_id + self.command = command + self.application = application + self.attributes = attributes + self.payload = payload + self.debug_logging = debug_logging + self.locator = locator + self.path_hint = path_hint + self.max_elements = max_elements + self.output_format = output_format + self.action_name = action_name + self.action_value = action_value + self.sub_commands = sub_commands + } +} + +// Locator for finding elements +public struct Locator: Codable { + public var match_all: Bool? + public var criteria: [String: String] + public var root_element_path_hint: [String]? + public var requireAction: String? + public var computed_name_contains: String? + + enum CodingKeys: String, CodingKey { + case match_all + case criteria + case root_element_path_hint + case requireAction = "require_action" + case computed_name_contains + } + + public init(match_all: Bool? = nil, criteria: [String: String] = [:], root_element_path_hint: [String]? = nil, requireAction: String? = nil, computed_name_contains: String? = nil) { + self.match_all = match_all + self.criteria = criteria + self.root_element_path_hint = root_element_path_hint + self.requireAction = requireAction + self.computed_name_contains = computed_name_contains + } +} + +// Response for query command (single element) +public struct QueryResponse: Codable { + public var command_id: String + public var success: Bool + public var command: String + public var data: AXElement? + public var attributes: ElementAttributes? + public var error: String? + public var debug_logs: [String]? + + public init(command_id: String, success: Bool = true, command: String = "getFocusedElement", data: AXElement? = nil, attributes: ElementAttributes? = nil, error: String? = nil, debug_logs: [String]? = nil) { + self.command_id = command_id + self.success = success + self.command = command + self.data = data + self.attributes = attributes + self.error = error + self.debug_logs = debug_logs + } +} + +// Response for collect_all command (multiple elements) +public struct MultiQueryResponse: Codable { + public var command_id: String + public var elements: [ElementAttributes]? + public var count: Int? + public var error: String? + public var debug_logs: [String]? + + public init(command_id: String, elements: [ElementAttributes]? = nil, count: Int? = nil, error: String? = nil, debug_logs: [String]? = nil) { + self.command_id = command_id + self.elements = elements + self.count = count ?? elements?.count + self.error = error + self.debug_logs = debug_logs + } +} + +// Response for perform_action command +public struct PerformResponse: Codable { + public var command_id: String + public var success: Bool + public var error: String? + public var debug_logs: [String]? + + public init(command_id: String, success: Bool, error: String? = nil, debug_logs: [String]? = nil) { + self.command_id = command_id + self.success = success + self.error = error + self.debug_logs = debug_logs + } +} + +// Response for extract_text command +public struct TextContentResponse: Codable { + public var command_id: String + public var text_content: String? + public var error: String? + public var debug_logs: [String]? + + public init(command_id: String, text_content: String? = nil, error: String? = nil, debug_logs: [String]? = nil) { + self.command_id = command_id + self.text_content = text_content + self.error = error + self.debug_logs = debug_logs + } +} + + +// Generic error response +public struct ErrorResponse: Codable { + public var command_id: String + public var success: Bool + public var error: ErrorDetail + public var debug_logs: [String]? + + public init(command_id: String, error: String, debug_logs: [String]? = nil) { + self.command_id = command_id + self.success = false + self.error = ErrorDetail(message: error) + self.debug_logs = debug_logs + } +} + +public struct ErrorDetail: Codable { + public var message: String + + public init(message: String) { + self.message = message + } +} + +// Simple success response, e.g. for ping +public struct SimpleSuccessResponse: Codable, Equatable { + public var command_id: String + public var success: Bool + public var status: String + public var message: String + public var details: String? + public var debug_logs: [String]? + + public init(command_id: String, status: String, message: String, details: String? = nil, debug_logs: [String]? = nil) { + self.command_id = command_id + self.success = true + self.status = status + self.message = message + self.details = details + self.debug_logs = debug_logs + } +} + +// Placeholder for any additional models if needed + +public struct AXElement: Codable { + public var attributes: ElementAttributes? + public var path: [String]? + + public init(attributes: ElementAttributes?, path: [String]? = nil) { + self.attributes = attributes + self.path = path + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Core/ProcessUtils.swift b/Sources/AXorcist/Core/ProcessUtils.swift new file mode 100644 index 0000000..5e87d4b --- /dev/null +++ b/Sources/AXorcist/Core/ProcessUtils.swift @@ -0,0 +1,120 @@ +// ProcessUtils.swift - Utilities for process and application inspection. + +import Foundation +import AppKit // For NSRunningApplication, NSWorkspace + +// debug() is assumed to be globally available from Logging.swift + +@MainActor +public func pid(forAppIdentifier ident: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> pid_t? { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + dLog("ProcessUtils: Attempting to find PID for identifier: '\(ident)'") + + if ident == "focused" { + dLog("ProcessUtils: Identifier is 'focused'. Checking frontmost application.") + if let frontmostApp = NSWorkspace.shared.frontmostApplication { + dLog("ProcessUtils: Frontmost app is '\(frontmostApp.localizedName ?? "nil")' (PID: \(frontmostApp.processIdentifier), BundleID: \(frontmostApp.bundleIdentifier ?? "nil"), Terminated: \(frontmostApp.isTerminated))") + return frontmostApp.processIdentifier + } else { + dLog("ProcessUtils: NSWorkspace.shared.frontmostApplication returned nil.") + return nil + } + } + + dLog("ProcessUtils: Trying by bundle identifier '\(ident)'.") + let appsByBundleID = NSRunningApplication.runningApplications(withBundleIdentifier: ident) + if !appsByBundleID.isEmpty { + dLog("ProcessUtils: Found \(appsByBundleID.count) app(s) by bundle ID '\(ident)'.") + for (index, app) in appsByBundleID.enumerated() { + dLog("ProcessUtils: App [\(index)] - Name: '\(app.localizedName ?? "nil")', PID: \(app.processIdentifier), BundleID: '\(app.bundleIdentifier ?? "nil")', Terminated: \(app.isTerminated)") + } + if let app = appsByBundleID.first(where: { !$0.isTerminated }) { + dLog("ProcessUtils: Using first non-terminated app found by bundle ID: '\(app.localizedName ?? "nil")' (PID: \(app.processIdentifier))") + return app.processIdentifier + } else { + dLog("ProcessUtils: All apps found by bundle ID '\(ident)' are terminated or list was empty initially but then non-empty (should not happen).") + } + } else { + dLog("ProcessUtils: No applications found for bundle identifier '\(ident)'.") + } + + dLog("ProcessUtils: Trying by localized name (case-insensitive) '\(ident)'.") + let allApps = NSWorkspace.shared.runningApplications + if let appByName = allApps.first(where: { !$0.isTerminated && $0.localizedName?.lowercased() == ident.lowercased() }) { + dLog("ProcessUtils: Found non-terminated app by localized name: '\(appByName.localizedName ?? "nil")' (PID: \(appByName.processIdentifier), BundleID: '\(appByName.bundleIdentifier ?? "nil")')") + return appByName.processIdentifier + } else { + dLog("ProcessUtils: No non-terminated app found matching localized name '\(ident)'. Found \(allApps.filter { $0.localizedName?.lowercased() == ident.lowercased() }.count) terminated or non-matching apps by this name.") + } + + dLog("ProcessUtils: Trying by path '\(ident)'.") + let potentialPath = (ident as NSString).expandingTildeInPath + if FileManager.default.fileExists(atPath: potentialPath), + let bundle = Bundle(path: potentialPath), + let bundleId = bundle.bundleIdentifier { + dLog("ProcessUtils: Path '\(potentialPath)' resolved to bundle '\(bundleId)'. Looking up running apps with this bundle ID.") + let appsByResolvedBundleID = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) + if !appsByResolvedBundleID.isEmpty { + dLog("ProcessUtils: Found \(appsByResolvedBundleID.count) app(s) by resolved bundle ID '\(bundleId)'.") + for (index, app) in appsByResolvedBundleID.enumerated() { + dLog("ProcessUtils: App [\(index)] from path - Name: '\(app.localizedName ?? "nil")', PID: \(app.processIdentifier), BundleID: '\(app.bundleIdentifier ?? "nil")', Terminated: \(app.isTerminated)") + } + if let app = appsByResolvedBundleID.first(where: { !$0.isTerminated }) { + dLog("ProcessUtils: Using first non-terminated app found by path (via bundle ID '\(bundleId)'): '\(app.localizedName ?? "nil")' (PID: \(app.processIdentifier))") + return app.processIdentifier + } else { + dLog("ProcessUtils: All apps for bundle ID '\(bundleId)' (from path) are terminated.") + } + } else { + dLog("ProcessUtils: No running applications found for bundle identifier '\(bundleId)' derived from path '\(potentialPath)'.") + } + } else { + dLog("ProcessUtils: Identifier '\(ident)' is not a valid file path or bundle info could not be read.") + } + + dLog("ProcessUtils: Trying by interpreting '\(ident)' as a PID string.") + if let pidInt = Int32(ident) { + if let appByPid = NSRunningApplication(processIdentifier: pidInt), !appByPid.isTerminated { + dLog("ProcessUtils: Found non-terminated app by PID string '\(ident)': '\(appByPid.localizedName ?? "nil")' (PID: \(appByPid.processIdentifier), BundleID: '\(appByPid.bundleIdentifier ?? "nil")')") + return pidInt + } else { + if NSRunningApplication(processIdentifier: pidInt)?.isTerminated == true { + dLog("ProcessUtils: String '\(ident)' is a PID, but the app is terminated.") + } else { + dLog("ProcessUtils: String '\(ident)' looked like a PID but no running application found for it.") + } + } + } + + dLog("ProcessUtils: PID not found for identifier: '\(ident)'") + return nil +} + +@MainActor +func findFrontmostApplicationPid(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> pid_t? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + dLog("ProcessUtils: findFrontmostApplicationPid called.") + if let frontmostApp = NSWorkspace.shared.frontmostApplication { + dLog("ProcessUtils: Frontmost app for findFrontmostApplicationPid is '\(frontmostApp.localizedName ?? "nil")' (PID: \(frontmostApp.processIdentifier), BundleID: '\(frontmostApp.bundleIdentifier ?? "nil")', Terminated: \(frontmostApp.isTerminated))") + return frontmostApp.processIdentifier + } else { + dLog("ProcessUtils: NSWorkspace.shared.frontmostApplication returned nil in findFrontmostApplicationPid.") + return nil + } +} + +public func getParentProcessName(isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + let parentPid = getppid() + dLog("ProcessUtils: Parent PID is \(parentPid).") + if let parentApp = NSRunningApplication(processIdentifier: parentPid) { + dLog("ProcessUtils: Parent app is '\(parentApp.localizedName ?? "nil")' (BundleID: '\(parentApp.bundleIdentifier ?? "nil")')") + return parentApp.localizedName ?? parentApp.bundleIdentifier + } + dLog("ProcessUtils: Could not get NSRunningApplication for parent PID \(parentPid).") + return nil +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/AttributeHelpers.swift b/Sources/AXorcist/Search/AttributeHelpers.swift new file mode 100644 index 0000000..d25768e --- /dev/null +++ b/Sources/AXorcist/Search/AttributeHelpers.swift @@ -0,0 +1,377 @@ +// AttributeHelpers.swift - Contains functions for fetching and formatting element attributes + +import Foundation +import ApplicationServices // For AXUIElement related types +import CoreGraphics // For potential future use with geometry types from attributes + +// Note: This file assumes Models (for ElementAttributes, AnyCodable), +// Logging (for debug), AccessibilityConstants, and Utils (for axValue) are available in the same module. +// And now Element for the new element wrapper. + +// Define AttributeData and AttributeSource here as they are not found by the compiler +public enum AttributeSource: String, Codable { + case direct // Directly from an AXAttribute + case computed // Derived by this tool +} + +public struct AttributeData: Codable { + public let value: AnyCodable + public let source: AttributeSource +} + +// MARK: - Element Summary Helpers + +// Removed getSingleElementSummary as it was unused. + +// MARK: - Internal Fetch Logic Helpers + +// Approach using direct property access within a switch statement +@MainActor +private func extractDirectPropertyValue(for attributeName: String, from element: Element, outputFormat: OutputFormat, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> (value: Any?, handled: Bool) { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + var extractedValue: Any? + var handled = true + + // Ensure logging parameters are passed to Element methods + switch attributeName { + case kAXPathHintAttribute: + extractedValue = element.attribute(Attribute(kAXPathHintAttribute), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXRoleAttribute: + extractedValue = element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXSubroleAttribute: + extractedValue = element.subrole(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXTitleAttribute: + extractedValue = element.title(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXDescriptionAttribute: + extractedValue = element.description(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXEnabledAttribute: + let val = element.isEnabled(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + extractedValue = val + if outputFormat == .text_content { extractedValue = val?.description ?? kAXNotAvailableString } + case kAXFocusedAttribute: + let val = element.isFocused(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + extractedValue = val + if outputFormat == .text_content { extractedValue = val?.description ?? kAXNotAvailableString } + case kAXHiddenAttribute: + let val = element.isHidden(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + extractedValue = val + if outputFormat == .text_content { extractedValue = val?.description ?? kAXNotAvailableString } + case isIgnoredAttributeKey: + let val = element.isIgnored(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + extractedValue = val + if outputFormat == .text_content { extractedValue = val ? "true" : "false" } + case "PID": + let val = element.pid(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + extractedValue = val + if outputFormat == .text_content { extractedValue = val?.description ?? kAXNotAvailableString } + case kAXElementBusyAttribute: + let val = element.isElementBusy(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + extractedValue = val + if outputFormat == .text_content { extractedValue = val?.description ?? kAXNotAvailableString } + default: + handled = false + } + currentDebugLogs.append(contentsOf: tempLogs) // Collect logs from Element method calls + return (extractedValue, handled) +} + +@MainActor +private func determineAttributesToFetch(requestedAttributes: [String], forMultiDefault: Bool, targetRole: String?, element: Element, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> [String] { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var attributesToFetch = requestedAttributes + if forMultiDefault { + attributesToFetch = [kAXRoleAttribute, kAXValueAttribute, kAXTitleAttribute, kAXIdentifierAttribute] + if let role = targetRole, role == kAXStaticTextRole { + attributesToFetch = [kAXRoleAttribute, kAXValueAttribute, kAXIdentifierAttribute] + } + } else if attributesToFetch.isEmpty { + var attrNames: CFArray? + if AXUIElementCopyAttributeNames(element.underlyingElement, &attrNames) == .success, let names = attrNames as? [String] { + attributesToFetch.append(contentsOf: names) + dLog("determineAttributesToFetch: No specific attributes requested, fetched all \(names.count) available: \(names.joined(separator: ", "))") + } else { + dLog("determineAttributesToFetch: No specific attributes requested and failed to fetch all available names.") + } + } + return attributesToFetch +} + +// MARK: - Public Attribute Getters + +@MainActor +public func getElementAttributes(_ element: Element, requestedAttributes: [String], forMultiDefault: Bool = false, targetRole: String? = nil, outputFormat: OutputFormat = .smart, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> ElementAttributes { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls, cleared and appended for each. + var result = ElementAttributes() + let valueFormatOption: ValueFormatOption = (outputFormat == .verbose) ? .verbose : .default + + tempLogs.removeAll() + dLog("getElementAttributes starting for element: \(element.briefDescription(option: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs)), format: \(outputFormat)") + currentDebugLogs.append(contentsOf: tempLogs) + + let attributesToFetch = determineAttributesToFetch(requestedAttributes: requestedAttributes, forMultiDefault: forMultiDefault, targetRole: targetRole, element: element, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + dLog("Attributes to fetch: \(attributesToFetch.joined(separator: ", "))") + + for attr in attributesToFetch { + var tempCallLogs: [String] = [] // Logs for a specific attribute fetching call + if attr == kAXParentAttribute { + tempCallLogs.removeAll() + let parent = element.parent(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs) + result[kAXParentAttribute] = formatParentAttribute(parent, outputFormat: outputFormat, valueFormatOption: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs) // formatParentAttribute will manage its own logs now + currentDebugLogs.append(contentsOf: tempCallLogs) // Collect logs from element.parent and formatParentAttribute + continue + } else if attr == kAXChildrenAttribute { + tempCallLogs.removeAll() + let children = element.children(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs) + result[attr] = formatChildrenAttribute(children, outputFormat: outputFormat, valueFormatOption: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs) // formatChildrenAttribute will manage its own logs + currentDebugLogs.append(contentsOf: tempCallLogs) + continue + } else if attr == kAXFocusedUIElementAttribute { + tempCallLogs.removeAll() + let focused = element.focusedElement(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs) + result[attr] = AnyCodable(formatFocusedUIElementAttribute(focused, outputFormat: outputFormat, valueFormatOption: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs)) + currentDebugLogs.append(contentsOf: tempCallLogs) + continue + } + + tempCallLogs.removeAll() + let (directValue, wasHandledDirectly) = extractDirectPropertyValue(for: attr, from: element, outputFormat: outputFormat, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs) + currentDebugLogs.append(contentsOf: tempCallLogs) + var finalValueToStore: Any? + + if wasHandledDirectly { + finalValueToStore = directValue + dLog("Attribute '\(attr)' handled directly, value: \(String(describing: directValue))") + } else { + tempCallLogs.removeAll() + let rawCFValue: CFTypeRef? = element.rawAttributeValue(named: attr, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempCallLogs) + currentDebugLogs.append(contentsOf: tempCallLogs) + if outputFormat == .text_content { + finalValueToStore = formatRawCFValueForTextContent(rawCFValue) + } else { + finalValueToStore = formatCFTypeRef(rawCFValue, option: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + dLog("Attribute '\(attr)' fetched via rawAttributeValue, formatted value: \(String(describing: finalValueToStore))") + } + + if outputFormat == .smart { + if let strVal = finalValueToStore as? String, + (strVal.isEmpty || strVal == "" || strVal == "AXValue (Illegal)" || strVal.contains("Unknown CFType") || strVal == kAXNotAvailableString) { + dLog("Smart format: Skipping attribute '\(attr)' with unhelpful value: \(strVal)") + continue + } + } + result[attr] = AnyCodable(finalValueToStore) + } + + tempLogs.removeAll() + if result[computedNameAttributeKey] == nil { + if let name = element.computedName(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) { + result[computedNameAttributeKey] = AnyCodable(AttributeData(value: AnyCodable(name), source: .computed)) + dLog("Added ComputedName: \(name)") + } + } + currentDebugLogs.append(contentsOf: tempLogs) + + tempLogs.removeAll() + if result[isClickableAttributeKey] == nil { + let isButton = (element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) == kAXButtonRole) + let hasPressAction = element.isActionSupported(kAXPressAction, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + if isButton || hasPressAction { + result[isClickableAttributeKey] = AnyCodable(AttributeData(value: AnyCodable(true), source: .computed)) + dLog("Added IsClickable: true (button: \(isButton), pressAction: \(hasPressAction))") + } + } + currentDebugLogs.append(contentsOf: tempLogs) + + tempLogs.removeAll() + if outputFormat == .verbose && result[computedPathAttributeKey] == nil { + let path = element.generatePathString(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + result[computedPathAttributeKey] = AnyCodable(path) + dLog("Added ComputedPath (verbose): \(path)") + } + currentDebugLogs.append(contentsOf: tempLogs) + + populateActionNamesAttribute(for: element, result: &result, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + + dLog("getElementAttributes finished. Result keys: \(result.keys.joined(separator: ", "))") + return result +} + +@MainActor +private func populateActionNamesAttribute(for element: Element, result: inout ElementAttributes, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + if result[kAXActionNamesAttribute] != nil { + dLog("populateActionNamesAttribute: Already present or explicitly requested, skipping.") + return + } + currentDebugLogs.append(contentsOf: tempLogs) // Appending potentially empty tempLogs, for consistency, though it does nothing here. + + var actionsToStore: [String]? + tempLogs.removeAll() + if let currentActions = element.supportedActions(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs), !currentActions.isEmpty { + actionsToStore = currentActions + dLog("populateActionNamesAttribute: Got \(currentActions.count) from supportedActions.") + } else { + dLog("populateActionNamesAttribute: supportedActions was nil or empty. Trying kAXActionsAttribute.") + tempLogs.removeAll() // Clear before next call that uses it + if let fallbackActions: [String] = element.attribute(Attribute<[String]>(kAXActionsAttribute), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs), !fallbackActions.isEmpty { + actionsToStore = fallbackActions + dLog("populateActionNamesAttribute: Got \(fallbackActions.count) from kAXActionsAttribute fallback.") + } + } + currentDebugLogs.append(contentsOf: tempLogs) + + tempLogs.removeAll() + let pressActionSupported = element.isActionSupported(kAXPressAction, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs) + dLog("populateActionNamesAttribute: kAXPressAction supported: \(pressActionSupported).") + if pressActionSupported { + if actionsToStore == nil { actionsToStore = [kAXPressAction] } + else if !actionsToStore!.contains(kAXPressAction) { actionsToStore!.append(kAXPressAction) } + } + + if let finalActions = actionsToStore, !finalActions.isEmpty { + result[kAXActionNamesAttribute] = AnyCodable(finalActions) + dLog("populateActionNamesAttribute: Final actions: \(finalActions.joined(separator: ", ")).") + } else { + tempLogs.removeAll() + let primaryNil = element.supportedActions(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) == nil + currentDebugLogs.append(contentsOf: tempLogs) + tempLogs.removeAll() + let fallbackNil = element.attribute(Attribute<[String]>(kAXActionsAttribute), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) == nil + currentDebugLogs.append(contentsOf: tempLogs) + if primaryNil && fallbackNil && !pressActionSupported { + result[kAXActionNamesAttribute] = AnyCodable(kAXNotAvailableString) + dLog("populateActionNamesAttribute: All action sources nil/unsupported. Set to kAXNotAvailableString.") + } else { + result[kAXActionNamesAttribute] = AnyCodable("\(kAXNotAvailableString) (no specific actions found or list empty)") + dLog("populateActionNamesAttribute: Some action source present but list empty. Set to verbose kAXNotAvailableString.") + } + } +} + +// MARK: - Attribute Formatting Helpers + +// Helper function to format the parent attribute +@MainActor +private func formatParentAttribute(_ parent: Element?, outputFormat: OutputFormat, valueFormatOption: ValueFormatOption, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> AnyCodable { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + guard let parentElement = parent else { return AnyCodable(nil as String?) } + if outputFormat == .text_content { + return AnyCodable("Element: \(parentElement.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? "?Role")") + } else { + return AnyCodable(parentElement.briefDescription(option: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs)) + } +} + +// Helper function to format the children attribute +@MainActor +private func formatChildrenAttribute(_ children: [Element]?, outputFormat: OutputFormat, valueFormatOption: ValueFormatOption, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> AnyCodable { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + guard let actualChildren = children, !actualChildren.isEmpty else { return AnyCodable("[]") } + if outputFormat == .text_content { + return AnyCodable("Array of \(actualChildren.count) Element(s)") + } else if outputFormat == .verbose { + var childrenSummaries: [String] = [] + for childElement in actualChildren { + childrenSummaries.append(childElement.briefDescription(option: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs)) + } + return AnyCodable("[\(childrenSummaries.joined(separator: ", "))]") + } else { // .smart output + return AnyCodable("Array of \(actualChildren.count) children") + } +} + +// Helper function to format the focused UI element attribute +@MainActor +private func formatFocusedUIElementAttribute(_ focusedElement: Element?, outputFormat: OutputFormat, valueFormatOption: ValueFormatOption, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> AnyCodable { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + guard let actualFocusedElement = focusedElement else { return AnyCodable(nil as String?) } + if outputFormat == .text_content { + return AnyCodable("Element: \(actualFocusedElement.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? "?Role")") + } else { + return AnyCodable(actualFocusedElement.briefDescription(option: valueFormatOption, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs)) + } +} + +/// Encodes the given ElementAttributes dictionary into a new dictionary containing +/// a single key "json_representation" with the JSON string as its value. +/// If encoding fails, returns a dictionary with an error message. +@MainActor +public func encodeAttributesToJSONStringRepresentation(_ attributes: ElementAttributes) -> ElementAttributes { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted // Or .sortedKeys for deterministic output if needed + do { + let jsonData = try encoder.encode(attributes) // attributes is [String: AnyCodable] + if let jsonString = String(data: jsonData, encoding: .utf8) { + return ["json_representation": AnyCodable(jsonString)] + } else { + return ["error": AnyCodable("Failed to convert encoded JSON data to string")] + } + } catch { + return ["error": AnyCodable("Failed to encode attributes to JSON: \(error.localizedDescription)")] + } +} + +// MARK: - Computed Attributes + +// New helper function to get only computed/heuristic attributes for matching +@MainActor +public func getComputedAttributes(for element: Element, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> ElementAttributes { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + var attributes: ElementAttributes = [:] + + tempLogs.removeAll() + dLog("getComputedAttributes for element: \(element.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs))") + currentDebugLogs.append(contentsOf: tempLogs) + + tempLogs.removeAll() + if let name = element.computedName(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) { + attributes[computedNameAttributeKey] = AnyCodable(AttributeData(value: AnyCodable(name), source: .computed)) + dLog("ComputedName: \(name)") + } + currentDebugLogs.append(contentsOf: tempLogs) + + tempLogs.removeAll() + let isButton = (element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) == kAXButtonRole) + currentDebugLogs.append(contentsOf: tempLogs) // Collect logs from role call + tempLogs.removeAll() + let hasPressAction = element.isActionSupported(kAXPressAction, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + currentDebugLogs.append(contentsOf: tempLogs) // Collect logs from isActionSupported call + + if isButton || hasPressAction { + attributes[isClickableAttributeKey] = AnyCodable(AttributeData(value: AnyCodable(true), source: .computed)) + dLog("IsClickable: true (button: \(isButton), pressAction: \(hasPressAction))") + } + + // Ensure other computed attributes like ComputedPath also use methods with logging if they exist. + // For now, this focuses on the direct errors. + + return attributes +} + +// MARK: - Attribute Formatting Helpers (Additional) + +// Helper function to format a raw CFTypeRef for .text_content output +@MainActor +private func formatRawCFValueForTextContent(_ rawValue: CFTypeRef?) -> String { + guard let value = rawValue else { return kAXNotAvailableString } + let typeID = CFGetTypeID(value) + if typeID == CFStringGetTypeID() { return (value as! String) } + else if typeID == CFAttributedStringGetTypeID() { return (value as! NSAttributedString).string } + else if typeID == AXValueGetTypeID() { + let axVal = value as! AXValue + return formatAXValue(axVal, option: .default) // Assumes formatAXValue returns String + } else if typeID == CFNumberGetTypeID() { return (value as! NSNumber).stringValue } + else if typeID == CFBooleanGetTypeID() { return CFBooleanGetValue((value as! CFBoolean)) ? "true" : "false" } + else { return "<\(CFCopyTypeIDDescription(typeID) as String? ?? "ComplexType")>" } +} + +// Any other attribute-specific helper functions could go here in the future. \ No newline at end of file diff --git a/Sources/AXorcist/Search/AttributeMatcher.swift b/Sources/AXorcist/Search/AttributeMatcher.swift new file mode 100644 index 0000000..b65ca71 --- /dev/null +++ b/Sources/AXorcist/Search/AttributeMatcher.swift @@ -0,0 +1,173 @@ +import Foundation +import ApplicationServices // For AXUIElement, CFTypeRef etc. + +// debug() is assumed to be globally available from Logging.swift +// DEBUG_LOGGING_ENABLED is a global public var from Logging.swift + +@MainActor +internal func attributesMatch(element: Element, matchDetails: [String: String], depth: Int, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + + let criteriaDesc = matchDetails.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") + let roleForLog = element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? "nil" + let titleForLog = element.title(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? "nil" + dLog("attributesMatch [D\(depth)]: Check. Role=\(roleForLog), Title=\(titleForLog). Criteria: [\(criteriaDesc)]") + + if !matchComputedNameAttributes(element: element, computedNameEquals: matchDetails[computedNameAttributeKey + "_equals"], computedNameContains: matchDetails[computedNameAttributeKey + "_contains"], depth: depth, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + return false + } + + for (key, expectedValue) in matchDetails { + if key == computedNameAttributeKey + "_equals" || key == computedNameAttributeKey + "_contains" { continue } + if key == kAXRoleAttribute { continue } // Already handled by ElementSearch's role check or not a primary filter here + + if key == kAXEnabledAttribute || key == kAXFocusedAttribute || key == kAXHiddenAttribute || key == kAXElementBusyAttribute || key == isIgnoredAttributeKey || key == kAXMainAttribute { + if !matchBooleanAttribute(element: element, key: key, expectedValueString: expectedValue, depth: depth, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + return false + } + continue + } + + if key == kAXActionNamesAttribute || key == kAXAllowedValuesAttribute || key == kAXChildrenAttribute { + if !matchArrayAttribute(element: element, key: key, expectedValueString: expectedValue, depth: depth, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + return false + } + continue + } + + if !matchStringAttribute(element: element, key: key, expectedValueString: expectedValue, depth: depth, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + return false + } + } + + dLog("attributesMatch [D\(depth)]: All attributes MATCHED criteria.") + return true +} + +@MainActor +internal func matchStringAttribute(element: Element, key: String, expectedValueString: String, depth: Int, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + + if let currentValue = element.attribute(Attribute(key), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) { + if currentValue != expectedValueString { + dLog("attributesMatch [D\(depth)]: Attribute '\(key)' expected '\(expectedValueString)', but found '\(currentValue)'. No match.") + return false + } + return true + } else { + if expectedValueString.lowercased() == "nil" || expectedValueString == kAXNotAvailableString || expectedValueString.isEmpty { + dLog("attributesMatch [D\(depth)]: Attribute '\(key)' not found, but expected value ('\(expectedValueString)') suggests absence is OK. Match for this key.") + return true + } else { + dLog("attributesMatch [D\(depth)]: Attribute '\(key)' (expected '\(expectedValueString)') not found or not convertible to String. No match.") + return false + } + } +} + +@MainActor +internal func matchArrayAttribute(element: Element, key: String, expectedValueString: String, depth: Int, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + + guard let expectedArray = decodeExpectedArray(fromString: expectedValueString, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + dLog("matchArrayAttribute [D\(depth)]: Could not decode expected array string '\(expectedValueString)' for attribute '\(key)'. No match.") + return false + } + + var actualArray: [String]? = nil + if key == kAXActionNamesAttribute { + actualArray = element.supportedActions(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + } else if key == kAXAllowedValuesAttribute { + actualArray = element.attribute(Attribute<[String]>(key), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + } else if key == kAXChildrenAttribute { + actualArray = element.children(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs)?.map { childElement -> String in + var childLogs: [String] = [] + return childElement.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &childLogs) ?? "UnknownRole" + } + } else { + dLog("matchArrayAttribute [D\(depth)]: Unknown array key '\(key)'. This function needs to be extended for this key.") + return false + } + + if let actual = actualArray { + if Set(actual) != Set(expectedArray) { + dLog("matchArrayAttribute [D\(depth)]: Array Attribute '\(key)' expected '\(expectedArray)', but found '\(actual)'. Sets differ. No match.") + return false + } + return true + } else { + if expectedArray.isEmpty { + dLog("matchArrayAttribute [D\(depth)]: Array Attribute '\(key)' not found, but expected array was empty. Match for this key.") + return true + } + dLog("matchArrayAttribute [D\(depth)]: Array Attribute '\(key)' (expected '\(expectedValueString)') not found in element. No match.") + return false + } +} + +@MainActor +internal func matchBooleanAttribute(element: Element, key: String, expectedValueString: String, depth: Int, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + var currentBoolValue: Bool? + + switch key { + case kAXEnabledAttribute: currentBoolValue = element.isEnabled(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXFocusedAttribute: currentBoolValue = element.isFocused(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXHiddenAttribute: currentBoolValue = element.isHidden(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXElementBusyAttribute: currentBoolValue = element.isElementBusy(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case isIgnoredAttributeKey: currentBoolValue = element.isIgnored(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + case kAXMainAttribute: currentBoolValue = element.attribute(Attribute(key), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + default: + dLog("matchBooleanAttribute [D\(depth)]: Unknown boolean key '\(key)'. This should not happen.") + return false + } + + if let actualBool = currentBoolValue { + let expectedBool = expectedValueString.lowercased() == "true" + if actualBool != expectedBool { + dLog("attributesMatch [D\(depth)]: Boolean Attribute '\(key)' expected '\(expectedBool)', but found '\(actualBool)'. No match.") + return false + } + return true + } else { + dLog("attributesMatch [D\(depth)]: Boolean Attribute '\(key)' (expected '\(expectedValueString)') not found in element. No match.") + return false + } +} + +@MainActor +internal func matchComputedNameAttributes(element: Element, computedNameEquals: String?, computedNameContains: String?, depth: Int, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Bool { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For Element method calls + + if computedNameEquals == nil && computedNameContains == nil { + return true + } + + // getComputedAttributes will need logging parameters + let computedAttrs = getComputedAttributes(for: element, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + if let currentComputedNameAny = computedAttrs[computedNameAttributeKey]?.value, // Assuming .value is how you get it from the AttributeData struct + let currentComputedName = currentComputedNameAny as? String { + if let equals = computedNameEquals { + if currentComputedName != equals { + dLog("matchComputedNameAttributes [D\(depth)]: ComputedName '\(currentComputedName)' != '\(equals)'. No match.") + return false + } + } + if let contains = computedNameContains { + if !currentComputedName.localizedCaseInsensitiveContains(contains) { + dLog("matchComputedNameAttributes [D\(depth)]: ComputedName '\(currentComputedName)' does not contain '\(contains)'. No match.") + return false + } + } + return true + } else { + dLog("matchComputedNameAttributes [D\(depth)]: Locator requires ComputedName (equals: \(computedNameEquals ?? "nil"), contains: \(computedNameContains ?? "nil")), but element has none. No match.") + return false + } +} + diff --git a/Sources/AXorcist/Search/ElementSearch.swift b/Sources/AXorcist/Search/ElementSearch.swift new file mode 100644 index 0000000..3489280 --- /dev/null +++ b/Sources/AXorcist/Search/ElementSearch.swift @@ -0,0 +1,200 @@ +// ElementSearch.swift - Contains search and element collection logic + +import Foundation +import ApplicationServices + +// Variable DEBUG_LOGGING_ENABLED is expected to be globally available from Logging.swift +// Element is now the primary type for UI elements. + +// decodeExpectedArray MOVED to Utils/GeneralParsingUtils.swift + +enum ElementMatchStatus { + case fullMatch // Role, attributes, and (if specified) action all match + case partialMatch_actionMissing // Role and attributes match, but a required action is missing + case noMatch // Role or attributes do not match +} + +@MainActor +internal func evaluateElementAgainstCriteria(element: Element, locator: Locator, actionToVerify: String?, depth: Int, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> ElementMatchStatus { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + + var tempLogs: [String] = [] // For calls to Element methods that need their own log scope temporarily + + let currentElementRoleForLog: String? = element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + let wantedRoleFromCriteria = locator.criteria[kAXRoleAttribute] + var roleMatchesCriteria = false + + if let currentRole = currentElementRoleForLog, let roleToMatch = wantedRoleFromCriteria, !roleToMatch.isEmpty, roleToMatch != "*" { + roleMatchesCriteria = (currentRole == roleToMatch) + } else { + roleMatchesCriteria = true // Wildcard/empty/nil role in criteria is a match + let wantedRoleStr = wantedRoleFromCriteria ?? "any" + let currentRoleStr = currentElementRoleForLog ?? "nil" + dLog("evaluateElementAgainstCriteria [D\(depth)]: Wildcard/empty/nil role in criteria ('\(wantedRoleStr)') considered a match for element role \(currentRoleStr).") + } + + if !roleMatchesCriteria { + dLog("evaluateElementAgainstCriteria [D\(depth)]: Role mismatch. Element role: \(currentElementRoleForLog ?? "nil"), Expected: \(wantedRoleFromCriteria ?? "any"). No match.") + return .noMatch + } + + // Role matches, now check other attributes + // attributesMatch will also need isDebugLoggingEnabled, currentDebugLogs + if !attributesMatch(element: element, matchDetails: locator.criteria, depth: depth, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + // attributesMatch itself will log the specific mismatch reason + dLog("evaluateElementAgainstCriteria [D\(depth)]: attributesMatch returned false. No match.") + return .noMatch + } + + // Role and attributes match. Now check for required action. + if let requiredAction = actionToVerify, !requiredAction.isEmpty { + if !element.isActionSupported(requiredAction, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) { + dLog("evaluateElementAgainstCriteria [D\(depth)]: Role & Attributes matched, but required action '\(requiredAction)' is MISSING.") + return .partialMatch_actionMissing + } + dLog("evaluateElementAgainstCriteria [D\(depth)]: Role, Attributes, and Required Action '\(requiredAction)' all MATCH.") + } else { + dLog("evaluateElementAgainstCriteria [D\(depth)]: Role & Attributes matched. No action to verify or action already included in locator.criteria for attributesMatch.") + } + + return .fullMatch +} + +@MainActor +public func search(element: Element, + locator: Locator, + requireAction: String?, + depth: Int = 0, + maxDepth: Int = DEFAULT_MAX_DEPTH_SEARCH, + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String]) -> Element? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For calls to Element methods + + let criteriaDesc = locator.criteria.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") + let roleStr = element.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? "nil" + let titleStr = element.title(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? "N/A" + dLog("search [D\(depth)]: Visiting. Role: \(roleStr), Title: \(titleStr). Locator Criteria: [\(criteriaDesc)], Action: \(requireAction ?? "none")") + + if depth > maxDepth { + let briefDesc = element.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + dLog("search [D\(depth)]: Max depth \(maxDepth) reached for element \(briefDesc).") + return nil + } + + let matchStatus = evaluateElementAgainstCriteria(element: element, + locator: locator, + actionToVerify: requireAction, + depth: depth, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs) // Pass through logs + + if matchStatus == .fullMatch { + let briefDesc = element.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + dLog("search [D\(depth)]: evaluateElementAgainstCriteria returned .fullMatch for \(briefDesc). Returning element.") + return element + } + + let briefDesc = element.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + if matchStatus == .partialMatch_actionMissing { + dLog("search [D\(depth)]: Element \(briefDesc) matched criteria but missed action '\(requireAction ?? "")'. Continuing child search.") + } + if matchStatus == .noMatch { + dLog("search [D\(depth)]: Element \(briefDesc) did not match criteria. Continuing child search.") + } + + let childrenToSearch: [Element] = element.children(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? [] + + if !childrenToSearch.isEmpty { + for childElement in childrenToSearch { + if let found = search(element: childElement, locator: locator, requireAction: requireAction, depth: depth + 1, maxDepth: maxDepth, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) { + return found + } + } + } + return nil +} + +@MainActor +public func collectAll( + appElement: Element, + locator: Locator, + currentElement: Element, + depth: Int, + maxDepth: Int, + maxElements: Int, + currentPath: [Element], + elementsBeingProcessed: inout Set, + foundElements: inout [Element], + isDebugLoggingEnabled: Bool, + currentDebugLogs: inout [String] // Added logging parameter +) { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var tempLogs: [String] = [] // For calls to Element methods + + let briefDescCurrent = currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) + + if elementsBeingProcessed.contains(currentElement) || currentPath.contains(currentElement) { + dLog("collectAll [D\(depth)]: Cycle detected or element \(briefDescCurrent) already processed/in path.") + return + } + elementsBeingProcessed.insert(currentElement) + + if foundElements.count >= maxElements { + dLog("collectAll [D\(depth)]: Max elements limit of \(maxElements) reached before processing \(briefDescCurrent).") + elementsBeingProcessed.remove(currentElement) + return + } + if depth > maxDepth { + dLog("collectAll [D\(depth)]: Max depth \(maxDepth) reached for \(briefDescCurrent).") + elementsBeingProcessed.remove(currentElement) + return + } + + let criteriaDesc = locator.criteria.map { "\($0.key)=\($0.value)" }.joined(separator: ", ") + dLog("collectAll [D\(depth)]: Visiting \(briefDescCurrent). Criteria: [\(criteriaDesc)], Action: \(locator.requireAction ?? "none")") + + let matchStatus = evaluateElementAgainstCriteria(element: currentElement, + locator: locator, + actionToVerify: locator.requireAction, + depth: depth, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs) // Pass through logs + + if matchStatus == .fullMatch { + if foundElements.count < maxElements { + if !foundElements.contains(currentElement) { + foundElements.append(currentElement) + dLog("collectAll [D\(depth)]: Added \(briefDescCurrent). Hits: \(foundElements.count)/\(maxElements)") + } else { + dLog("collectAll [D\(depth)]: Element \(briefDescCurrent) was a full match but already in foundElements.") + } + } else { + dLog("collectAll [D\(depth)]: Element \(briefDescCurrent) was a full match but maxElements (\(maxElements)) already reached.") + } + } + + let childrenToExplore: [Element] = currentElement.children(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs) ?? [] + elementsBeingProcessed.remove(currentElement) + + let newPath = currentPath + [currentElement] + for child in childrenToExplore { + if foundElements.count >= maxElements { + dLog("collectAll [D\(depth)]: Max elements (\(maxElements)) reached during child traversal of \(briefDescCurrent). Stopping further exploration for this branch.") + break + } + collectAll( + appElement: appElement, + locator: locator, + currentElement: child, + depth: depth + 1, + maxDepth: maxDepth, + maxElements: maxElements, + currentPath: newPath, + elementsBeingProcessed: &elementsBeingProcessed, + foundElements: &foundElements, + isDebugLoggingEnabled: isDebugLoggingEnabled, + currentDebugLogs: ¤tDebugLogs // Pass through logs + ) + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Search/PathUtils.swift b/Sources/AXorcist/Search/PathUtils.swift new file mode 100644 index 0000000..7404b52 --- /dev/null +++ b/Sources/AXorcist/Search/PathUtils.swift @@ -0,0 +1,81 @@ +// PathUtils.swift - Utilities for parsing paths and navigating element hierarchies. + +import Foundation +import ApplicationServices // For Element, AXUIElement and kAX...Attribute constants + +// Assumes Element is defined (likely via AXSwift an extension or typealias) +// debug() is assumed to be globally available from Logging.swift +// axValue() is assumed to be globally available from ValueHelpers.swift +// kAXWindowRole, kAXWindowsAttribute, kAXChildrenAttribute, kAXRoleAttribute from AccessibilityConstants.swift + +public func parsePathComponent(_ path: String) -> (role: String, index: Int)? { + let pattern = #"(\w+)\[(\d+)\]"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(path.startIndex.. Element? { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + var currentElement = rootElement + for pathComponent in pathHint { + guard let (role, index) = parsePathComponent(pathComponent) else { + dLog("Failed to parse path component: \(pathComponent)") + return nil + } + + var tempBriefDescLogs: [String] = [] // Placeholder for briefDescription logs + + if role.lowercased() == "window" || role.lowercased() == kAXWindowRole.lowercased() { + guard let windowUIElements: [AXUIElement] = axValue(of: currentElement.underlyingElement, attr: kAXWindowsAttribute, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + dLog("PathUtils: AXWindows attribute could not be fetched as [AXUIElement].") + return nil + } + dLog("PathUtils: Fetched \(windowUIElements.count) AXUIElements for AXWindows.") + + let windows: [Element] = windowUIElements.map { Element($0) } + dLog("PathUtils: Mapped to \(windows.count) Elements.") + + guard index < windows.count else { + dLog("PathUtils: Index \(index) is out of bounds for windows array (count: \(windows.count)). Component: \(pathComponent).") + return nil + } + currentElement = windows[index] + } else { + let currentElementDesc = currentElement.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempBriefDescLogs) // Placeholder call + guard let allChildrenUIElements: [AXUIElement] = axValue(of: currentElement.underlyingElement, attr: kAXChildrenAttribute, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + dLog("PathUtils: AXChildren attribute could not be fetched as [AXUIElement] for element \(currentElementDesc) while processing \(pathComponent).") + return nil + } + dLog("PathUtils: Fetched \(allChildrenUIElements.count) AXUIElements for AXChildren of \(currentElementDesc) for \(pathComponent).") + + let allChildren: [Element] = allChildrenUIElements.map { Element($0) } + dLog("PathUtils: Mapped to \(allChildren.count) Elements for children of \(currentElementDesc) for \(pathComponent).") + + guard !allChildren.isEmpty else { + dLog("No children found for element \(currentElementDesc) while processing component: \(pathComponent)") + return nil + } + + let matchingChildren = allChildren.filter { + guard let childRole: String = axValue(of: $0.underlyingElement, attr: kAXRoleAttribute, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { return false } + return childRole.lowercased() == role.lowercased() + } + + guard index < matchingChildren.count else { + dLog("Child not found for component: \(pathComponent) at index \(index). Role: \(role). For element \(currentElementDesc). Matching children count: \(matchingChildren.count)") + return nil + } + currentElement = matchingChildren[index] + } + } + return currentElement +} \ No newline at end of file diff --git a/Sources/AXorcist/Utils/CustomCharacterSet.swift b/Sources/AXorcist/Utils/CustomCharacterSet.swift new file mode 100644 index 0000000..a35b1bd --- /dev/null +++ b/Sources/AXorcist/Utils/CustomCharacterSet.swift @@ -0,0 +1,42 @@ +import Foundation + +// CustomCharacterSet struct from Scanner +public struct CustomCharacterSet { + private var characters: Set + public init(characters: Set) { + self.characters = characters + } + public init(charactersInString: String) { + self.characters = Set(charactersInString.map { $0 }) + } + public func contains(_ character: Character) -> Bool { + return self.characters.contains(character) + } + public mutating func add(_ characters: Set) { + self.characters.formUnion(characters) + } + public func adding(_ characters: Set) -> CustomCharacterSet { + return CustomCharacterSet(characters: self.characters.union(characters)) + } + public mutating func remove(_ characters: Set) { + self.characters.subtract(characters) + } + public func removing(_ characters: Set) -> CustomCharacterSet { + return CustomCharacterSet(characters: self.characters.subtracting(characters)) + } + + // Add some common character sets that might be useful, similar to Foundation.CharacterSet + public static var whitespacesAndNewlines: CustomCharacterSet { + return CustomCharacterSet(charactersInString: " \t\n\r") + } + public static var decimalDigits: CustomCharacterSet { + return CustomCharacterSet(charactersInString: "0123456789") + } + public static func punctuationAndSymbols() -> CustomCharacterSet { // Example + // This would need a more comprehensive list based on actual needs + return CustomCharacterSet(charactersInString: ".,:;?!()[]{}-_=+") // Simplified set + } + public static func characters(in string: String) -> CustomCharacterSet { + return CustomCharacterSet(charactersInString: string) + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Utils/GeneralParsingUtils.swift b/Sources/AXorcist/Utils/GeneralParsingUtils.swift new file mode 100644 index 0000000..1e0216c --- /dev/null +++ b/Sources/AXorcist/Utils/GeneralParsingUtils.swift @@ -0,0 +1,84 @@ +// GeneralParsingUtils.swift - General parsing utilities + +import Foundation + +// TODO: Consider if this should be public or internal depending on usage across modules if this were a larger project. +// For AXHelper, internal or public within the module is fine. + +/// Decodes a string representation of an array into an array of strings. +/// The input string can be JSON-style (e.g., "["item1", "item2"]") +/// or a simple comma-separated list (e.g., "item1, item2", with or without brackets). +public func decodeExpectedArray(fromString: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> [String]? { + // This function itself does not log, but takes the parameters as it's called by functions that do. + // func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + + let trimmedString = fromString.trimmingCharacters(in: .whitespacesAndNewlines) + + // Try JSON deserialization first for robustness with escaped characters, etc. + if trimmedString.hasPrefix("[") && trimmedString.hasSuffix("]") { + if let jsonData = trimmedString.data(using: .utf8) { + do { + // Attempt to decode as [String] + if let array = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String] { + return array + } + // Fallback: if it decodes as [Any], convert elements to String + else if let anyArray = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [Any] { + return anyArray.compactMap { item -> String? in + if let strItem = item as? String { + return strItem + } else { + // For non-string items, convert to string representation + // This handles numbers, booleans if they were in the JSON array + return String(describing: item) + } + } + } + } catch { + // dLog("JSON decoding failed for string: \(trimmedString). Error: \(error.localizedDescription)") + } + } + } + + // Fallback to comma-separated parsing if JSON fails or string isn't JSON-like + // Remove brackets first if they exist for comma parsing + var stringToSplit = trimmedString + if stringToSplit.hasPrefix("[") && stringToSplit.hasSuffix("]") { + stringToSplit = String(stringToSplit.dropFirst().dropLast()) + } + + // If the string (after removing brackets) is empty, it represents an empty array. + if stringToSplit.isEmpty && trimmedString.hasPrefix("[") && trimmedString.hasSuffix("]") { + return [] + } + // If the original string was just "[]" or "", and after stripping it's empty, it's an empty array. + // If it was empty to begin with, or just spaces, it's not a valid array string by this func's def. + if stringToSplit.isEmpty && !trimmedString.isEmpty && !(trimmedString.hasPrefix("[") && trimmedString.hasSuffix("]")) { + // e.g. input was " " which became "", not a valid array representation + // or input was "item" which is not an array string + // However, if original was "[]", stringToSplit is empty, should return [] + // If original was "", stringToSplit is empty, should return nil (or based on stricter needs) + // This function is lenient: if after stripping brackets it's empty, it's an empty array. + // If the original was non-empty but not bracketed, and became empty after trimming, it's not an array. + } + + // Handle case where stringToSplit might be empty, meaning an empty array if brackets were present. + if stringToSplit.isEmpty { + // If original string was "[]", then stringToSplit is empty, return [] + // If original was "", then stringToSplit is empty, return nil (not an array format) + return (trimmedString.hasPrefix("[") && trimmedString.hasSuffix("]")) ? [] : nil + } + + return stringToSplit.components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + // Do not filter out empty strings if they are explicitly part of the list e.g. "a,,b" + // The original did .filter { !$0.isEmpty }, which might be too aggressive. + // For now, let's keep all components and let caller decide if empty strings are valid. + // Re-evaluating: if a component is empty after trimming, it usually means an empty element. + // Example: "[a, ,b]" -> ["a", "", "b"]. Example "a," -> ["a", ""]. + // The original .filter { !$0.isEmpty } would turn "a,," into ["a"] + // Let's retain the original filtering of completely empty strings after trim, + // as "[a,,b]" usually implies "[a,b]" in lenient contexts. + // If explicit empty strings like `["a", "", "b"]` are needed, JSON is better. + .filter { !$0.isEmpty } +} \ No newline at end of file diff --git a/Sources/AXorcist/Utils/Scanner.swift b/Sources/AXorcist/Utils/Scanner.swift new file mode 100644 index 0000000..6c14076 --- /dev/null +++ b/Sources/AXorcist/Utils/Scanner.swift @@ -0,0 +1,323 @@ +// Scanner.swift - Custom scanner implementation (Scanner) + +import Foundation + +// String extension MOVED to String+HelperExtensions.swift +// CustomCharacterSet struct MOVED to CustomCharacterSet.swift + +// Scanner class from Scanner +class Scanner { + + // MARK: - Properties and Initialization + let string: String + var location: Int = 0 + init(string: String) { + self.string = string + } + var isAtEnd: Bool { + return self.location >= self.string.count + } + + // MARK: - Character Set Scanning + // A more conventional scanUpTo (scans until a character in the set is found) + @discardableResult func scanUpToCharacters(in charSet: CustomCharacterSet) -> String? { + let initialLocation = self.location + var scannedCharacters = String() + + while self.location < self.string.count { + let currentChar = self.string[self.location] + if charSet.contains(currentChar) { break } + scannedCharacters.append(currentChar) + self.location += 1 + } + + return scannedCharacters.isEmpty && self.location == initialLocation ? nil : scannedCharacters + } + + // Scans characters that ARE in the provided set (like original Scanner's scanUpTo/scan(characterSet:)) + @discardableResult func scanCharacters(in charSet: CustomCharacterSet) -> String? { + let initialLocation = self.location + var characters = String() + + while self.location < self.string.count, charSet.contains(self.string[self.location]) { + characters.append(self.string[self.location]) + self.location += 1 + } + + if characters.isEmpty { + self.location = initialLocation // Revert if nothing was scanned + return nil + } + return characters + } + + @discardableResult func scan(characterSet: CustomCharacterSet) -> Character? { + guard self.location < self.string.count else { return nil } + let character = self.string[self.location] + guard characterSet.contains(character) else { return nil } + self.location += 1 + return character + } + + @discardableResult func scan(characterSet: CustomCharacterSet) -> String? { + var characters = String() + while let character: Character = self.scan(characterSet: characterSet) { + characters.append(character) + } + return characters.isEmpty ? nil : characters + } + + // MARK: - Specific Character and String Scanning + @discardableResult func scan(character: Character, options: NSString.CompareOptions = []) -> Character? { + guard self.location < self.string.count else { return nil } + let characterString = String(character) + if characterString.compare(String(self.string[self.location]), options: options, range: nil, locale: nil) == .orderedSame { + self.location += 1 + return character + } + return nil + } + + @discardableResult func scan(string: String, options: NSString.CompareOptions = []) -> String? { + let savepoint = self.location + var characters = String() + + for character in string { + if let charScanned = self.scan(character: character, options: options) { + characters.append(charScanned) + } else { + self.location = savepoint // Revert on failure + return nil + } + } + + // If we scanned the whole string, it's a match. + return characters.count == string.count ? characters : { self.location = savepoint; return nil }() + } + + func scan(token: String, options: NSString.CompareOptions = []) -> String? { + self.scanWhitespaces() + return self.scan(string: token, options: options) + } + + func scan(strings: [String], options: NSString.CompareOptions = []) -> String? { + for stringEntry in strings { + if let scannedString = self.scan(string: stringEntry, options: options) { + return scannedString + } + } + return nil + } + + func scan(tokens: [String], options: NSString.CompareOptions = []) -> String? { + self.scanWhitespaces() + return self.scan(strings: tokens, options: options) + } + + // MARK: - Integer Scanning + func scanSign() -> Int? { + return self.scan(dictionary: ["+": 1, "-": -1]) + } + + // Private helper that scans and returns a string of digits + private func scanDigits() -> String? { + return self.scanCharacters(in: .decimalDigits) + } + + // Calculate integer value from digit string with given base + private func integerValue(from digitString: String, base: T = 10) -> T { + return digitString.reduce(T(0)) { result, char in + result * base + T(Int(String(char))!) + } + } + + func scanUnsignedInteger() -> T? { + self.scanWhitespaces() + guard let digitString = self.scanDigits() else { return nil } + return integerValue(from: digitString) + } + + func scanInteger() -> T? { + let savepoint = self.location + self.scanWhitespaces() + + // Parse sign if present + let sign = self.scanSign() ?? 1 + + // Parse digits + guard let digitString = self.scanDigits() else { + // If we found a sign but no digits, revert and return nil + if sign != 1 { + self.location = savepoint + } + return nil + } + + // Calculate final value with sign applied + return T(sign) * integerValue(from: digitString) + } + + // MARK: - Floating Point Scanning + // Attempt to parse Double with a compact implementation + func scanDouble() -> Double? { + scanWhitespaces() + let initialLocation = self.location + + // Parse sign + let sign: Double = (scan(character: "-") != nil) ? -1.0 : { _ = scan(character: "+"); return 1.0 }() + + // Buffer to build the numeric string + var numberStr = "" + var hasDigits = false + + // Parse integer part + if let digits = scanCharacters(in: .decimalDigits) { + numberStr += digits + hasDigits = true + } + + // Parse fractional part + let dotLocation = location + if scan(character: ".") != nil { + if let fractionDigits = scanCharacters(in: .decimalDigits) { + numberStr += "." + numberStr += fractionDigits + hasDigits = true + } else { + // Revert dot scan if not followed by digits + location = dotLocation + } + } + + // If no digits found in either integer or fractional part, revert and return nil + if !hasDigits { + location = initialLocation + return nil + } + + // Parse exponent + var exponent = 0 + let expLocation = location + if scan(character: "e", options: .caseInsensitive) != nil { + let expSign: Double = (scan(character: "-") != nil) ? -1.0 : { _ = scan(character: "+"); return 1.0 }() + + if let expDigits = scanCharacters(in: .decimalDigits), let expValue = Int(expDigits) { + exponent = Int(expSign) * expValue + } else { + // Revert exponent scan if not followed by valid digits + location = expLocation + } + } + + // Convert to final double value + if var value = Double(numberStr) { + value *= sign + if exponent != 0 { + value *= pow(10.0, Double(exponent)) + } + return value + } + + // If conversion fails, revert everything + location = initialLocation + return nil + } + + // Mapping hex characters to their integer values + private static let hexValues: [Character: Int] = [ + "0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, + "a": 10, "b": 11, "c": 12, "d": 13, "e": 14, "f": 15, + "A": 10, "B": 11, "C": 12, "D": 13, "E": 14, "F": 15 + ] + + func scanHexadecimalInteger() -> T? { + let initialLoc = location + let hexCharSet = CustomCharacterSet(charactersInString: Self.characterSets.hexDigits) + + var value: T = 0 + var digitCount = 0 + + while let char: Character = scan(characterSet: hexCharSet), + let digit = Self.hexValues[char] { + value = value * 16 + T(digit) + digitCount += 1 + } + + if digitCount == 0 { + location = initialLoc // Revert if nothing was scanned + return nil + } + + return value + } + + // Helper function for power calculation with FloatingPoint types + private func scannerPower(base: T, exponent: Int) -> T { + if exponent == 0 { return T(1) } + if exponent < 0 { return T(1) / scannerPower(base: base, exponent: -exponent) } + var result = T(1) + for _ in 0.. String? { + scanWhitespaces() + let savepoint = location + + // Scan first character (must be letter or underscore) + guard let firstChar: Character = scan(characterSet: Self.identifierFirstCharSet) else { + location = savepoint + return nil + } + + // Begin with the first character + var identifier = String(firstChar) + + // Scan remaining characters (can include digits) + while let nextChar: Character = scan(characterSet: Self.identifierFollowingCharSet) { + identifier.append(nextChar) + } + + return identifier + } + // MARK: - Whitespace Scanning + func scanWhitespaces() { + _ = self.scanCharacters(in: .whitespacesAndNewlines) + } + // MARK: - Dictionary-based Scanning + func scan(dictionary: [String: T], options: NSString.CompareOptions = []) -> T? { + for (key, value) in dictionary { + if self.scan(string: key, options: options) != nil { + // Original Scanner asserts string == key, which is true if scan(string:) returns non-nil. + return value + } + } + return nil + } + + // Helper to get the remaining string + var remainingString: String { + if isAtEnd { return "" } + let startIndex = string.index(string.startIndex, offsetBy: location) + return String(string[startIndex...]) + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Utils/String+HelperExtensions.swift b/Sources/AXorcist/Utils/String+HelperExtensions.swift new file mode 100644 index 0000000..3058c7f --- /dev/null +++ b/Sources/AXorcist/Utils/String+HelperExtensions.swift @@ -0,0 +1,31 @@ +import Foundation + +// String extension from Scanner +extension String { + subscript (i: Int) -> Character { + return self[index(startIndex, offsetBy: i)] + } + func range(from range: NSRange) -> Range? { + return Range(range, in: self) + } + func range(from range: Range) -> NSRange { + return NSRange(range, in: self) + } + var firstLine: String? { + var line: String? + self.enumerateLines { + line = $0 + $1 = true + } + return line + } +} + +extension Optional { + var orNilString: String { + switch self { + case .some(let value): return "\(value)" + case .none: return "nil" + } + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Utils/TextExtraction.swift b/Sources/AXorcist/Utils/TextExtraction.swift new file mode 100644 index 0000000..8173cb5 --- /dev/null +++ b/Sources/AXorcist/Utils/TextExtraction.swift @@ -0,0 +1,42 @@ +// TextExtraction.swift - Utilities for extracting textual content from Elements. + +import Foundation +import ApplicationServices // For Element and kAX...Attribute constants + +// Assumes Element is defined and has an `attribute(String) -> String?` method. +// Constants like kAXValueAttribute are expected to be available (e.g., from AccessibilityConstants.swift) +// axValue() is assumed to be globally available from ValueHelpers.swift + +@MainActor +public func extractTextContent(element: Element, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + dLog("Extracting text content for element: \(element.briefDescription(option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs))") + var texts: [String] = [] + let textualAttributes = [ + kAXValueAttribute, kAXTitleAttribute, kAXDescriptionAttribute, kAXHelpAttribute, + kAXPlaceholderValueAttribute, kAXLabelValueAttribute, kAXRoleDescriptionAttribute, + // Consider adding kAXStringForRangeParameterizedAttribute if dealing with large text views for performance + // kAXSelectedTextAttribute could also be relevant depending on use case + ] + for attrName in textualAttributes { + var tempLogs: [String] = [] // For the axValue call + // Pass the received logging parameters to axValue + if let strValue: String = axValue(of: element.underlyingElement, attr: attrName, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: &tempLogs), !strValue.isEmpty, strValue.lowercased() != kAXNotAvailableString.lowercased() { + texts.append(strValue) + currentDebugLogs.append(contentsOf: tempLogs) // Collect logs from axValue + } else { + currentDebugLogs.append(contentsOf: tempLogs) // Still collect logs if value was nil/empty + } + } + + // Deduplicate while preserving order + var uniqueTexts: [String] = [] + var seenTexts = Set() + for text in texts { + if !seenTexts.contains(text) { + uniqueTexts.append(text) + seenTexts.insert(text) + } + } + return uniqueTexts.joined(separator: "\n") +} \ No newline at end of file diff --git a/Sources/AXorcist/Values/Scannable.swift b/Sources/AXorcist/Values/Scannable.swift new file mode 100644 index 0000000..c0fe687 --- /dev/null +++ b/Sources/AXorcist/Values/Scannable.swift @@ -0,0 +1,44 @@ +import Foundation + +// MARK: - Scannable Protocol +protocol Scannable { + init?(_ scanner: Scanner) +} + +// MARK: - Scannable Conformance +extension Int: Scannable { + init?(_ scanner: Scanner) { + if let value: Int = scanner.scanInteger() { self = value } + else { return nil } + } +} + +extension UInt: Scannable { + init?(_ scanner: Scanner) { + if let value: UInt = scanner.scanUnsignedInteger() { self = value } + else { return nil } + } +} + +extension Float: Scannable { + init?(_ scanner: Scanner) { + // Using the custom scanDouble and casting + if let value = scanner.scanDouble() { self = Float(value) } + else { return nil } + } +} + +extension Double: Scannable { + init?(_ scanner: Scanner) { + if let value = scanner.scanDouble() { self = value } + else { return nil } + } +} + +extension Bool: Scannable { + init?(_ scanner: Scanner) { + scanner.scanWhitespaces() + if let value: Bool = scanner.scan(dictionary: ["true": true, "false": false], options: [.caseInsensitive]) { self = value } + else { return nil } + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Values/ValueFormatter.swift b/Sources/AXorcist/Values/ValueFormatter.swift new file mode 100644 index 0000000..074f8ee --- /dev/null +++ b/Sources/AXorcist/Values/ValueFormatter.swift @@ -0,0 +1,174 @@ +// ValueFormatter.swift - Utilities for formatting AX values into human-readable strings + +import Foundation +import ApplicationServices +import CoreGraphics // For CGPoint, CGSize, CGRect, CFRange + +// debug() is assumed to be globally available from Logging.swift +// stringFromAXValueType() is assumed to be available from ValueHelpers.swift +// axErrorToString() is assumed to be available from AccessibilityConstants.swift + +@MainActor +public enum ValueFormatOption { + case `default` // Concise, suitable for lists or brief views + case verbose // More detailed, suitable for focused inspection +} + +@MainActor +public func formatAXValue(_ axValue: AXValue, option: ValueFormatOption = .default) -> String { + let type = AXValueGetType(axValue) + var result = "AXValue (\(stringFromAXValueType(type)))" + + switch type { + case .cgPoint: + var point = CGPoint.zero + if AXValueGetValue(axValue, .cgPoint, &point) { + result = "x=\(point.x) y=\(point.y)" + if option == .verbose { result = "" } + } + case .cgSize: + var size = CGSize.zero + if AXValueGetValue(axValue, .cgSize, &size) { + result = "w=\(size.width) h=\(size.height)" + if option == .verbose { result = "" } + } + case .cgRect: + var rect = CGRect.zero + if AXValueGetValue(axValue, .cgRect, &rect) { + result = "x=\(rect.origin.x) y=\(rect.origin.y) w=\(rect.size.width) h=\(rect.size.height)" + if option == .verbose { result = "" } + } + case .cfRange: + var range = CFRange() + if AXValueGetValue(axValue, .cfRange, &range) { + result = "pos=\(range.location) len=\(range.length)" + if option == .verbose { result = "" } + } + case .axError: + var error = AXError.success + if AXValueGetValue(axValue, .axError, &error) { + result = axErrorToString(error) + if option == .verbose { result = "" } + } + case .illegal: + result = "Illegal AXValue" + default: + // For boolean type (rawValue 4) + if type.rawValue == 4 { + var boolResult: DarwinBoolean = false + if AXValueGetValue(axValue, type, &boolResult) { + result = boolResult.boolValue ? "true" : "false" + if option == .verbose { result = ""} + } + } + // Other types: return generic description. + // Consider if other specific AXValueTypes need custom formatting. + break + } + return result +} + +// Helper to escape strings for display (e.g. in logs or formatted output that isn't strict JSON) +private func escapeStringForDisplay(_ input: String) -> String { + var escaped = input + // More comprehensive escaping might be needed depending on the exact output context + // For now, handle common cases for human-readable display. + escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\") // Escape backslashes first + escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") // Escape double quotes + escaped = escaped.replacingOccurrences(of: "\n", with: "\\n") // Escape newlines + escaped = escaped.replacingOccurrences(of: "\t", with: "\\t") // Escape tabs + escaped = escaped.replacingOccurrences(of: "\r", with: "\\r") // Escape carriage returns + return escaped +} + +@MainActor +// Update signature to accept logging parameters +public func formatCFTypeRef(_ cfValue: CFTypeRef?, option: ValueFormatOption = .default, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String { + guard let value = cfValue else { return "" } + let typeID = CFGetTypeID(value) + // var tempLogs: [String] = [] // Removed as it was unused + + switch typeID { + case AXUIElementGetTypeID(): + let element = Element(value as! AXUIElement) + // Pass the received logging parameters to briefDescription + return element.briefDescription(option: option, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + case AXValueGetTypeID(): + return formatAXValue(value as! AXValue, option: option) + case CFStringGetTypeID(): + return "\"\(escapeStringForDisplay(value as! String))\"" // Used helper + case CFAttributedStringGetTypeID(): + return "\"\(escapeStringForDisplay((value as! NSAttributedString).string ))\"" // Used helper + case CFBooleanGetTypeID(): + return CFBooleanGetValue((value as! CFBoolean)) ? "true" : "false" + case CFNumberGetTypeID(): + return (value as! NSNumber).stringValue + case CFArrayGetTypeID(): + let cfArray = value as! CFArray + let count = CFArrayGetCount(cfArray) + if option == .verbose || count <= 5 { // Show contents for small arrays or if verbose + var swiftArray: [String] = [] + for i in 0..") + continue + } + // Pass logging parameters to recursive call + swiftArray.append(formatCFTypeRef(Unmanaged.fromOpaque(elementPtr).takeUnretainedValue(), option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs)) + } + return "[\(swiftArray.joined(separator: ","))]" + } else { + return "" + } + case CFDictionaryGetTypeID(): + let cfDict = value as! CFDictionary + let count = CFDictionaryGetCount(cfDict) + if option == .verbose || count <= 3 { // Show contents for small dicts or if verbose + var swiftDict: [String: String] = [:] + if let nsDict = cfDict as? [String: AnyObject] { + for (key, val) in nsDict { + // Pass logging parameters to recursive call + swiftDict[key] = formatCFTypeRef(val, option: .default, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } + // Sort by key for consistent output + let sortedItems = swiftDict.sorted { $0.key < $1.key } + .map { "\"\(escapeStringForDisplay($0.key))\": \($0.value)" } // Used helper for key, value is already formatted + return "{\(sortedItems.joined(separator: ","))}" + } else { + return "" + } + } else { + return "" + } + case CFURLGetTypeID(): + return (value as! URL).absoluteString + default: + let typeDescription = CFCopyTypeIDDescription(typeID) as String? ?? "Unknown CFType" + return "" + } +} + +// Add a helper to Element for a brief description +extension Element { + @MainActor + // Now a method to accept logging parameters + public func briefDescription(option: ValueFormatOption = .default, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> String { + // Call the new method versions of title, identifier, value, description, role + if let titleStr = self.title(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !titleStr.isEmpty { + let roleStr = self.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? "UnknownRole" + return "<\(roleStr): \"\(escapeStringForDisplay(titleStr))\">" + } + else if let identifierStr = self.identifier(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !identifierStr.isEmpty { + let roleStr = self.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? "UnknownRole" + return "<\(roleStr) id: \"\(escapeStringForDisplay(identifierStr))\">" + } else if let valueAny = self.value(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), let valueStr = valueAny as? String, !valueStr.isEmpty, valueStr.count < 50 { + let roleStr = self.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? "UnknownRole" + return "<\(roleStr) val: \"\(escapeStringForDisplay(valueStr))\">" + } else if let descStr = self.description(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs), !descStr.isEmpty, descStr.count < 50 { + let roleStr = self.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? "UnknownRole" + return "<\(roleStr) desc: \"\(escapeStringForDisplay(descStr))\">" + } + let roleStr = self.role(isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) ?? "UnknownRole" + return "<\(roleStr)>" + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Values/ValueHelpers.swift b/Sources/AXorcist/Values/ValueHelpers.swift new file mode 100644 index 0000000..fd99440 --- /dev/null +++ b/Sources/AXorcist/Values/ValueHelpers.swift @@ -0,0 +1,165 @@ +import Foundation +import ApplicationServices +import CoreGraphics // For CGPoint, CGSize etc. + +// debug() is assumed to be globally available from Logging.swift +// Constants like kAXPositionAttribute are assumed to be globally available from AccessibilityConstants.swift + +// ValueUnwrapper has been moved to its own file: ValueUnwrapper.swift + +// MARK: - Attribute Value Accessors + +@MainActor +public func copyAttributeValue(element: AXUIElement, attribute: String) -> CFTypeRef? { + var value: CFTypeRef? + // This function is low-level, avoid extensive logging here unless specifically for this function. + // Logging for attribute success/failure is better handled by the caller (axValue). + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { + return nil + } + return value +} + +@MainActor +public func axValue(of element: AXUIElement, attr: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> T? { + func dLog(_ message: String) { + if isDebugLoggingEnabled { + currentDebugLogs.append(message) + } + } + + // copyAttributeValue doesn't log, so no need to pass log params to it. + let rawCFValue = copyAttributeValue(element: element, attribute: attr) + + // ValueUnwrapper.unwrap also needs to be audited for logging. For now, assume it doesn't log or its logs are separate. + let unwrappedValue = ValueUnwrapper.unwrap(rawCFValue, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + + guard let value = unwrappedValue else { + // It's common for attributes to be missing or have no value. + // Only log if in debug mode and something was expected but not found, + // or if rawCFValue was non-nil but unwrapped to nil (which ValueUnwrapper might handle). + // For now, let's not log here, as Element.swift's rawAttributeValue also has checks. + return nil + } + + if T.self == String.self { + if let str = value as? String { return str as? T } + else if let attrStr = value as? NSAttributedString { return attrStr.string as? T } + dLog("axValue: Expected String for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == Bool.self { + if let boolVal = value as? Bool { return boolVal as? T } + else if let numVal = value as? NSNumber { return numVal.boolValue as? T } + dLog("axValue: Expected Bool for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == Int.self { + if let intVal = value as? Int { return intVal as? T } + else if let numVal = value as? NSNumber { return numVal.intValue as? T } + dLog("axValue: Expected Int for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == Double.self { + if let doubleVal = value as? Double { return doubleVal as? T } + else if let numVal = value as? NSNumber { return numVal.doubleValue as? T } + dLog("axValue: Expected Double for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == [AXUIElement].self { + if let anyArray = value as? [Any?] { + let result = anyArray.compactMap { item -> AXUIElement? in + guard let cfItem = item else { return nil } + // Ensure correct comparison for CFTypeRef type ID + if CFGetTypeID(cfItem as CFTypeRef) == AXUIElementGetTypeID() { // Directly use AXUIElementGetTypeID() + return (cfItem as! AXUIElement) + } + return nil + } + return result as? T + } + dLog("axValue: Expected [AXUIElement] for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == [Element].self { // Assuming Element is a struct wrapping AXUIElement + if let anyArray = value as? [Any?] { + let result = anyArray.compactMap { item -> Element? in + guard let cfItem = item else { return nil } + if CFGetTypeID(cfItem as CFTypeRef) == AXUIElementGetTypeID() { // Check underlying type + return Element(cfItem as! AXUIElement) + } + return nil + } + return result as? T + } + dLog("axValue: Expected [Element] for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == [String].self { + if let stringArray = value as? [Any?] { + let result = stringArray.compactMap { $0 as? String } + // Ensure all elements were successfully cast, otherwise it's not a homogenous [String] array + if result.count == stringArray.count { return result as? T } + } + dLog("axValue: Expected [String] for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + // CGPoint and CGSize are expected to be directly unwrapped by ValueUnwrapper to these types. + if T.self == CGPoint.self { + if let pointVal = value as? CGPoint { return pointVal as? T } + dLog("axValue: Expected CGPoint for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == CGSize.self { + if let sizeVal = value as? CGSize { return sizeVal as? T } + dLog("axValue: Expected CGSize for attribute '\(attr)', but got \(type(of: value)): \(value)") + return nil + } + + if T.self == AXUIElement.self { + if let cfValue = value as CFTypeRef?, CFGetTypeID(cfValue) == AXUIElementGetTypeID() { + return (cfValue as! AXUIElement) as? T + } + let typeDescription = String(describing: type(of: value)) + let valueDescription = String(describing: value) + dLog("axValue: Expected AXUIElement for attribute '\(attr)', but got \(typeDescription): \(valueDescription)") + return nil + } + + if let castedValue = value as? T { + return castedValue + } + + dLog("axValue: Fallback cast attempt for attribute '\(attr)' to type \(T.self) FAILED. Unwrapped value was \(type(of: value)): \(value)") + return nil +} + +// MARK: - AXValueType String Helper + +public func stringFromAXValueType(_ type: AXValueType) -> String { + switch type { + case .cgPoint: return "CGPoint (kAXValueCGPointType)" + case .cgSize: return "CGSize (kAXValueCGSizeType)" + case .cgRect: return "CGRect (kAXValueCGRectType)" + case .cfRange: return "CFRange (kAXValueCFRangeType)" + case .axError: return "AXError (kAXValueAXErrorType)" + case .illegal: return "Illegal (kAXValueIllegalType)" + default: + // AXValueType is not exhaustive in Swift's AXValueType enum from ApplicationServices. + // Common missing ones include Boolean (4), Number (5), Array (6), Dictionary (7), String (8), URL (9), etc. + // We rely on ValueUnwrapper to handle these based on CFGetTypeID. + // This function is mostly for AXValue encoded types. + if type.rawValue == 4 { // kAXValueBooleanType is often 4 but not in the public enum + return "Boolean (rawValue 4, contextually kAXValueBooleanType)" + } + return "Unknown AXValueType (rawValue: \(type.rawValue))" + } +} \ No newline at end of file diff --git a/Sources/AXorcist/Values/ValueParser.swift b/Sources/AXorcist/Values/ValueParser.swift new file mode 100644 index 0000000..a9af87e --- /dev/null +++ b/Sources/AXorcist/Values/ValueParser.swift @@ -0,0 +1,236 @@ +// AXValueParser.swift - Utilities for parsing string inputs into AX-compatible values + +import Foundation +import ApplicationServices +import CoreGraphics // For CGPoint, CGSize, CGRect, CFRange + +// debug() is assumed to be globally available from Logging.swift +// Constants are assumed to be globally available from AccessibilityConstants.swift +// Scanner and CustomCharacterSet are from Scanner.swift +// AccessibilityError is from AccessibilityError.swift + +// Inspired by UIElementInspector's UIElementUtilities.m + +// AXValueParseError enum has been removed and its cases merged into AccessibilityError. + +@MainActor +public func getCFTypeIDForAttribute(element: Element, attributeName: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> CFTypeID? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + guard let rawValue = element.rawAttributeValue(named: attributeName, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + dLog("getCFTypeIDForAttribute: Failed to get raw attribute value for '\(attributeName)'") + return nil + } + return CFGetTypeID(rawValue) +} + +@MainActor +public func getAXValueTypeForAttribute(element: Element, attributeName: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> AXValueType? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + guard let rawValue = element.rawAttributeValue(named: attributeName, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + dLog("getAXValueTypeForAttribute: Failed to get raw attribute value for '\(attributeName)'") + return nil + } + + guard CFGetTypeID(rawValue) == AXValueGetTypeID() else { + dLog("getAXValueTypeForAttribute: Attribute '\(attributeName)' is not an AXValue. TypeID: \(CFGetTypeID(rawValue))") + return nil + } + + let axValue = rawValue as! AXValue + return AXValueGetType(axValue) +} + + +// Main function to create CFTypeRef for setting an attribute +// It determines the type of the attribute and then calls the appropriate parser. +@MainActor +public func createCFTypeRefFromString(stringValue: String, forElement element: Element, attributeName: String, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) throws -> CFTypeRef? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + + guard let currentRawValue = element.rawAttributeValue(named: attributeName, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) else { + throw AccessibilityError.attributeNotReadable("Could not read current value for attribute '\(attributeName)' to determine type.") + } + + let typeID = CFGetTypeID(currentRawValue) + + if typeID == AXValueGetTypeID() { + let axValue = currentRawValue as! AXValue + let axValueType = AXValueGetType(axValue) + dLog("Attribute '\(attributeName)' is AXValue of type: \(stringFromAXValueType(axValueType))") + return try parseStringToAXValue(stringValue: stringValue, targetAXValueType: axValueType, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) + } else if typeID == CFStringGetTypeID() { + dLog("Attribute '\(attributeName)' is CFString. Returning stringValue as CFString.") + return stringValue as CFString + } else if typeID == CFNumberGetTypeID() { + dLog("Attribute '\(attributeName)' is CFNumber. Attempting to parse stringValue as Double then create CFNumber.") + if let doubleValue = Double(stringValue) { + return NSNumber(value: doubleValue) // CFNumber is toll-free bridged to NSNumber + } else if let intValue = Int(stringValue) { + return NSNumber(value: intValue) + } else { + throw AccessibilityError.valueParsingFailed(details: "Could not parse '\(stringValue)' as Double or Int for CFNumber attribute '\(attributeName)'") + } + } else if typeID == CFBooleanGetTypeID() { + dLog("Attribute '\(attributeName)' is CFBoolean. Attempting to parse stringValue as Bool.") + if stringValue.lowercased() == "true" { + return kCFBooleanTrue + } else if stringValue.lowercased() == "false" { + return kCFBooleanFalse + } else { + throw AccessibilityError.valueParsingFailed(details: "Could not parse '\(stringValue)' as Bool (true/false) for CFBoolean attribute '\(attributeName)'") + } + } + // TODO: Handle other CFTypeIDs like CFArray, CFDictionary if necessary for set-value. + // For now, focus on types directly convertible from string or AXValue structs. + + let typeDescription = CFCopyTypeIDDescription(typeID) as String? ?? "Unknown CFType" + throw AccessibilityError.attributeUnsupported("Setting attribute '\(attributeName)' of CFTypeID \(typeID) (\(typeDescription)) from string is not supported yet.") +} + + +// Parses a string into an AXValue for struct types like CGPoint, CGSize, CGRect, CFRange +@MainActor +private func parseStringToAXValue(stringValue: String, targetAXValueType: AXValueType, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) throws -> AXValue? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + var valueRef: AXValue? + + switch targetAXValueType { + case .cgPoint: + var x: Double = 0, y: Double = 0 + let components = stringValue.replacingOccurrences(of: " ", with: "").split(separator: ",") + if components.count == 2, + let xValStr = components[0].split(separator: "=").last, let xVal = Double(xValStr), + let yValStr = components[1].split(separator: "=").last, let yVal = Double(yValStr) { + x = xVal; y = yVal + } else if components.count == 2, let xVal = Double(components[0]), let yVal = Double(components[1]) { + x = xVal; y = yVal + } else { + let scanner = Scanner(string: stringValue) + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "xy:, \t\n")) + let xScanned = scanner.scanDouble() + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "xy:, \t\n")) + let yScanned = scanner.scanDouble() + if let xVal = xScanned, let yVal = yScanned { + x = xVal; y = yVal + } else { + dLog("parseStringToAXValue: CGPoint parsing failed for '\(stringValue)' via scanner.") + throw AccessibilityError.valueParsingFailed(details: "Could not parse '\(stringValue)' into CGPoint. Expected format like 'x=10,y=20' or '10,20'.") + } + } + var point = CGPoint(x: x, y: y) + valueRef = AXValueCreate(targetAXValueType, &point) + + case .cgSize: + var w: Double = 0, h: Double = 0 + let components = stringValue.replacingOccurrences(of: " ", with: "").split(separator: ",") + if components.count == 2, + let wValStr = components[0].split(separator: "=").last, let wVal = Double(wValStr), + let hValStr = components[1].split(separator: "=").last, let hVal = Double(hValStr) { + w = wVal; h = hVal + } else if components.count == 2, let wVal = Double(components[0]), let hVal = Double(components[1]) { + w = wVal; h = hVal + } else { + let scanner = Scanner(string: stringValue) + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "wh:, \t\n")) + let wScanned = scanner.scanDouble() + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "wh:, \t\n")) + let hScanned = scanner.scanDouble() + if let wVal = wScanned, let hVal = hScanned { + w = wVal; h = hVal + } else { + dLog("parseStringToAXValue: CGSize parsing failed for '\(stringValue)' via scanner.") + throw AccessibilityError.valueParsingFailed(details: "Could not parse '\(stringValue)' into CGSize. Expected format like 'w=100,h=50' or '100,50'.") + } + } + var size = CGSize(width: w, height: h) + valueRef = AXValueCreate(targetAXValueType, &size) + + case .cgRect: + var x: Double = 0, y: Double = 0, w: Double = 0, h: Double = 0 + let components = stringValue.replacingOccurrences(of: " ", with: "").split(separator: ",") + if components.count == 4, + let xStr = components[0].split(separator: "=").last, let xVal = Double(xStr), + let yStr = components[1].split(separator: "=").last, let yVal = Double(yStr), + let wStr = components[2].split(separator: "=").last, let wVal = Double(wStr), + let hStr = components[3].split(separator: "=").last, let hVal = Double(hStr) { + x = xVal; y = yVal; w = wVal; h = hVal + } else if components.count == 4, + let xVal = Double(components[0]), let yVal = Double(components[1]), + let wVal = Double(components[2]), let hVal = Double(components[3]) { + x = xVal; y = yVal; w = wVal; h = hVal + } else { + let scanner = Scanner(string: stringValue) + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "xywh:, \t\n")) + let xS_opt = scanner.scanDouble() + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "xywh:, \t\n")) + let yS_opt = scanner.scanDouble() + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "xywh:, \t\n")) + let wS_opt = scanner.scanDouble() + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "xywh:, \t\n")) + let hS_opt = scanner.scanDouble() + if let xS = xS_opt, let yS = yS_opt, let wS = wS_opt, let hS = hS_opt { + x = xS; y = yS; w = wS; h = hS + } else { + dLog("parseStringToAXValue: CGRect parsing failed for '\(stringValue)' via scanner.") + throw AccessibilityError.valueParsingFailed(details: "Could not parse '\(stringValue)' into CGRect. Expected format like 'x=0,y=0,w=100,h=50' or '0,0,100,50'.") + } + } + var rect = CGRect(x: x, y: y, width: w, height: h) + valueRef = AXValueCreate(targetAXValueType, &rect) + + case .cfRange: + var loc: Int = 0, len: Int = 0 + let components = stringValue.replacingOccurrences(of: " ", with: "").split(separator: ",") + if components.count == 2, + let locStr = components[0].split(separator: "=").last, let locVal = Int(locStr), + let lenStr = components[1].split(separator: "=").last, let lenVal = Int(lenStr) { + loc = locVal; len = lenVal + } else if components.count == 2, let locVal = Int(components[0]), let lenVal = Int(components[1]) { + loc = locVal; len = lenVal + } else { + let scanner = Scanner(string: stringValue) + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "loclen:, \t\n")) + let locScanned: Int? = scanner.scanInteger() + _ = scanner.scanCharacters(in: CustomCharacterSet(charactersInString: "loclen:, \t\n")) + let lenScanned: Int? = scanner.scanInteger() + if let locV = locScanned, let lenV = lenScanned { + loc = locV + len = lenV + } else { + dLog("parseStringToAXValue: CFRange parsing failed for '\(stringValue)' via scanner.") + throw AccessibilityError.valueParsingFailed(details: "Could not parse '\(stringValue)' into CFRange. Expected format like 'loc=0,len=10' or '0,10'.") + } + } + var range = CFRangeMake(loc, len) + valueRef = AXValueCreate(targetAXValueType, &range) + + case .illegal: + dLog("parseStringToAXValue: Attempted to parse for .illegal AXValueType.") + throw AccessibilityError.attributeUnsupported("Cannot parse value for AXValueType .illegal") + + case .axError: + dLog("parseStringToAXValue: Attempted to parse for .axError AXValueType.") + throw AccessibilityError.attributeUnsupported("Cannot set an attribute of AXValueType .axError") + + default: + if targetAXValueType.rawValue == 4 { + var boolVal: DarwinBoolean + if stringValue.lowercased() == "true" { boolVal = true } + else if stringValue.lowercased() == "false" { boolVal = false } + else { + dLog("parseStringToAXValue: Boolean parsing failed for '\(stringValue)' for AXValue.") + throw AccessibilityError.valueParsingFailed(details: "Could not parse '\(stringValue)' as boolean for AXValue.") + } + valueRef = AXValueCreate(targetAXValueType, &boolVal) + } else { + dLog("parseStringToAXValue: Unsupported AXValueType '\(stringFromAXValueType(targetAXValueType))' (rawValue: \(targetAXValueType.rawValue)).") + throw AccessibilityError.attributeUnsupported("Parsing for AXValueType '\(stringFromAXValueType(targetAXValueType))' (rawValue: \(targetAXValueType.rawValue)) from string is not supported yet.") + } + } + + if valueRef == nil { + dLog("parseStringToAXValue: AXValueCreate failed for type \(stringFromAXValueType(targetAXValueType)) with input '\(stringValue)'") + throw AccessibilityError.valueParsingFailed(details: "AXValueCreate failed for type \(stringFromAXValueType(targetAXValueType)) with input '\(stringValue)'") + } + return valueRef +} \ No newline at end of file diff --git a/Sources/AXorcist/Values/ValueUnwrapper.swift b/Sources/AXorcist/Values/ValueUnwrapper.swift new file mode 100644 index 0000000..d9259e1 --- /dev/null +++ b/Sources/AXorcist/Values/ValueUnwrapper.swift @@ -0,0 +1,92 @@ +import Foundation +import ApplicationServices +import CoreGraphics // For CGPoint, CGSize etc. + +// debug() is assumed to be globally available from Logging.swift +// Constants like kAXPositionAttribute are assumed to be globally available from AccessibilityConstants.swift + +// MARK: - ValueUnwrapper Utility +struct ValueUnwrapper { + @MainActor + static func unwrap(_ cfValue: CFTypeRef?, isDebugLoggingEnabled: Bool, currentDebugLogs: inout [String]) -> Any? { + func dLog(_ message: String) { if isDebugLoggingEnabled { currentDebugLogs.append(message) } } + guard let value = cfValue else { return nil } + let typeID = CFGetTypeID(value) + + switch typeID { + case ApplicationServices.AXUIElementGetTypeID(): + return value as! AXUIElement + case ApplicationServices.AXValueGetTypeID(): + let axVal = value as! AXValue + let axValueType = AXValueGetType(axVal) + + if axValueType.rawValue == 4 { // kAXValueBooleanType (private) + var boolResult: DarwinBoolean = false + if AXValueGetValue(axVal, axValueType, &boolResult) { + return boolResult.boolValue + } + } + + switch axValueType { + case .cgPoint: + var point = CGPoint.zero + return AXValueGetValue(axVal, .cgPoint, &point) ? point : nil + case .cgSize: + var size = CGSize.zero + return AXValueGetValue(axVal, .cgSize, &size) ? size : nil + case .cgRect: + var rect = CGRect.zero + return AXValueGetValue(axVal, .cgRect, &rect) ? rect : nil + case .cfRange: + var cfRange = CFRange() + return AXValueGetValue(axVal, .cfRange, &cfRange) ? cfRange : nil + case .axError: + var axErrorValue: AXError = .success + return AXValueGetValue(axVal, .axError, &axErrorValue) ? axErrorValue : nil + case .illegal: + dLog("ValueUnwrapper: Encountered AXValue with type .illegal") + return nil + @unknown default: // Added @unknown default to handle potential new AXValueType cases + dLog("ValueUnwrapper: AXValue with unhandled AXValueType: \(stringFromAXValueType(axValueType)).") + return axVal // Return the original AXValue if type is unknown + } + case CFStringGetTypeID(): + return (value as! CFString) as String + case CFAttributedStringGetTypeID(): + return (value as! NSAttributedString).string + case CFBooleanGetTypeID(): + return CFBooleanGetValue((value as! CFBoolean)) + case CFNumberGetTypeID(): + return value as! NSNumber + case CFArrayGetTypeID(): + let cfArray = value as! CFArray + var swiftArray: [Any?] = [] + for i in 0...fromOpaque(elementPtr).takeUnretainedValue(), isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs)) + } + return swiftArray + case CFDictionaryGetTypeID(): + let cfDict = value as! CFDictionary + var swiftDict: [String: Any?] = [:] + // Attempt to bridge to Swift dictionary directly if possible + if let nsDict = cfDict as? [String: AnyObject] { // Use AnyObject for broader compatibility + for (key, val) in nsDict { + swiftDict[key] = unwrap(val, isDebugLoggingEnabled: isDebugLoggingEnabled, currentDebugLogs: ¤tDebugLogs) // Unwrap the value + } + } else { + // Fallback for more complex CFDictionary structures if direct bridging fails + // This part requires careful handling of CFDictionary keys and values + // For now, we'll log if direct bridging fails, as full CFDictionary iteration is complex. + dLog("ValueUnwrapper: Failed to bridge CFDictionary to [String: AnyObject]. Full CFDictionary iteration not yet implemented here.") + } + return swiftDict + default: + dLog("ValueUnwrapper: Unhandled CFTypeID: \(typeID) - \(CFCopyTypeIDDescription(typeID) as String? ?? "Unknown"). Returning raw value.") + return value // Return the original value if CFType is not handled + } + } +} \ No newline at end of file diff --git a/Sources/axorc/axorc.swift b/Sources/axorc/axorc.swift new file mode 100644 index 0000000..2569c83 --- /dev/null +++ b/Sources/axorc/axorc.swift @@ -0,0 +1,773 @@ +import Foundation +import AXorcist +import ArgumentParser + +let AXORC_VERSION = "0.1.2a-config_fix" + +@main // Add @main if this is the executable's entry point +struct AXORCCommand: AsyncParsableCommand { // Changed to AsyncParsableCommand + static let configuration = CommandConfiguration( + commandName: "axorc", // commandName must come before abstract + abstract: "AXORC CLI - Handles JSON commands via various input methods. Version \\(AXORC_VERSION)" + ) + + @Flag(name: .long, help: "Enable debug logging for the command execution.") + var debug: Bool = false + + @Flag(name: .long, help: "Read JSON payload from STDIN.") + var stdin: Bool = false + + @Option(name: .long, help: "Read JSON payload from the specified file path.") + var file: String? + + @Argument(help: "Read JSON payload directly from this string argument. If other input flags (--stdin, --file) are used, this argument is ignored.") + var directPayload: String? = nil + + mutating func run() async throws { + var localDebugLogs: [String] = [] + if debug { + localDebugLogs.append("Debug logging enabled by --debug flag.") + } + + var receivedJsonString: String? = nil + var inputSourceDescription: String = "Unspecified" + var detailedInputError: String? = nil + + let activeInputFlags = (stdin ? 1 : 0) + (file != nil ? 1 : 0) + let positionalPayloadProvided = directPayload != nil && !(directPayload?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + + if activeInputFlags > 1 { + detailedInputError = "Error: Multiple input flags specified (--stdin, --file). Only one is allowed." + inputSourceDescription = detailedInputError! + } else if stdin { + inputSourceDescription = "STDIN" + let stdInputHandle = FileHandle.standardInput + let stdinData = stdInputHandle.readDataToEndOfFile() + if let str = String(data: stdinData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !str.isEmpty { + receivedJsonString = str + localDebugLogs.append("Successfully read \(str.count) chars from STDIN.") + } else { + detailedInputError = "Warning: STDIN flag specified, but no data or empty data received." + localDebugLogs.append(detailedInputError!) + } + } else if let filePath = file { + inputSourceDescription = "File: \(filePath)" + do { + let fileContent = try String(contentsOfFile: filePath, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + if fileContent.isEmpty { + detailedInputError = "Error: File '\(filePath)' is empty." + } else { + receivedJsonString = fileContent + localDebugLogs.append("Successfully read from file: \(filePath)") + } + } catch { + detailedInputError = "Error: Failed to read from file '\(filePath)': \(error.localizedDescription)" + } + if detailedInputError != nil { localDebugLogs.append(detailedInputError!) } + } else if let payload = directPayload, positionalPayloadProvided { + inputSourceDescription = "Direct Argument Payload" + receivedJsonString = payload.trimmingCharacters(in: .whitespacesAndNewlines) + localDebugLogs.append("Using direct argument payload. Length: \(receivedJsonString?.count ?? 0)") + } else if directPayload != nil && !positionalPayloadProvided { + detailedInputError = "Error: Direct argument payload was provided but was an empty string." + inputSourceDescription = detailedInputError! + localDebugLogs.append(detailedInputError!) + } else { + detailedInputError = "No JSON input method specified or chosen method yielded no data." + inputSourceDescription = detailedInputError! + localDebugLogs.append(detailedInputError!) + } + if detailedInputError != nil { localDebugLogs.append(detailedInputError!) } + + print("AXORC_JSON_OUTPUT_PREFIX:::") + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + if let errorToReport = detailedInputError, receivedJsonString == nil { + let errResponse = ErrorResponse(command_id: "input_error", error: ErrorResponse.ErrorDetail(message: errorToReport), debug_logs: debug ? localDebugLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + guard let jsonToProcess = receivedJsonString, !jsonToProcess.isEmpty else { + let finalErrorMsg = detailedInputError ?? "No JSON data successfully processed. Last input state: \(inputSourceDescription)." + var errorLogs = localDebugLogs; errorLogs.append(finalErrorMsg) + let errResponse = ErrorResponse(command_id: "no_json_data", error: ErrorResponse.ErrorDetail(message: finalErrorMsg), debug_logs: debug ? errorLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + do { + let commandEnvelope = try JSONDecoder().decode(CommandEnvelope.self, from: Data(jsonToProcess.utf8)) + var currentLogs = localDebugLogs + currentLogs.append("Decoded CommandEnvelope. Type: \(commandEnvelope.command), ID: \(commandEnvelope.command_id)") + + switch commandEnvelope.command { + case .ping: + let prefix = "Ping handled by AXORCCommand. Input source: " + let messageValue = inputSourceDescription + let successMessage = prefix + messageValue + currentLogs.append(successMessage) + + let details: String? + if let payloadData = jsonToProcess.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any], + let payloadDict = payload["payload"] as? [String: Any], + let payloadMessage = payloadDict["message"] as? String { + details = payloadMessage + } else { + details = nil + } + + let successResponse = SimpleSuccessResponse( + command_id: commandEnvelope.command_id, + success: true, // Explicitly true + status: "pong", + message: successMessage, + details: details, + debug_logs: debug ? currentLogs : nil + ) + if let data = try? encoder.encode(successResponse), let str = String(data: data, encoding: .utf8) { print(str) } + + case .getFocusedElement: + let axInstance = AXorcist() + var handlerLogs = currentLogs + + let commandIDForResponse = commandEnvelope.command_id + let appIdentifierForHandler = commandEnvelope.application + let requestedAttributesForHandler = commandEnvelope.attributes + + // Directly await the MainActor function. operationResult is non-optional. + let operationResult: HandlerResponse = await axInstance.handleGetFocusedElement( + for: appIdentifierForHandler, + requestedAttributes: requestedAttributesForHandler, + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, + currentDebugLogs: &handlerLogs + ) + // No semaphore needed + + // operationResult is now non-optional, so we can use it directly. + let actualResponse = operationResult + let finalDebugLogs = debug || (commandEnvelope.debug_logging ?? false) ? handlerLogs : nil + + fputs("[axorc DEBUG] Attempting to encode QueryResponse...\n", stderr) + let queryResponse = QueryResponse( + command_id: commandIDForResponse, + success: actualResponse.error == nil, + command: commandEnvelope.command.rawValue, + handlerResponse: actualResponse, + debug_logs: finalDebugLogs + ) + + do { + let data = try encoder.encode(queryResponse) + fputs("[axorc DEBUG] QueryResponse encoded to data. Size: \(data.count)\n", stderr) + if let str = String(data: data, encoding: .utf8) { + fputs("[axorc DEBUG] QueryResponse data converted to string. Length: \(str.count). Printing to stdout.\n", stderr) + print(str) // STDOUT + } else { + fputs("[axorc DEBUG] Failed to convert QueryResponse data to UTF8 string.\n", stderr) + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Failed to convert QueryResponse data to string (UTF8)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + fputs("[axorc DEBUG] Explicitly CAUGHT error during QueryResponse encoding: \(error)\n", stderr) + fputs("[axorc DEBUG] Error localizedDescription: \(error.localizedDescription)\n", stderr) + if let encodingError = error as? EncodingError { + fputs("[axorc DEBUG] EncodingError context: \(encodingError)\n", stderr) + } + + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Caught error during QueryResponse encoding: \(error.localizedDescription)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + case .getAttributes: + guard let locatorForHandler = commandEnvelope.locator else { + let errorMsg = "getAttributes command requires a locator but none was provided" + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + let axInstance = AXorcist() + var handlerLogs = currentLogs + + let commandIDForResponse = commandEnvelope.command_id + let appIdentifierForHandler = commandEnvelope.application + let requestedAttributesForHandler = commandEnvelope.attributes + let pathHintForHandler = commandEnvelope.path_hint + let maxDepthForHandler = commandEnvelope.max_elements + let outputFormatForHandler = commandEnvelope.output_format + + // Call the new handleGetAttributes method + let operationResult: HandlerResponse = await axInstance.handleGetAttributes( + for: appIdentifierForHandler, + locator: locatorForHandler, + requestedAttributes: requestedAttributesForHandler, + pathHint: pathHintForHandler, + maxDepth: maxDepthForHandler, + outputFormat: outputFormatForHandler, + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, + currentDebugLogs: &handlerLogs + ) + + let actualResponse = operationResult + let finalDebugLogs = debug || (commandEnvelope.debug_logging ?? false) ? handlerLogs : nil + + fputs("[axorc DEBUG] Attempting to encode QueryResponse for getAttributes...\n", stderr) + let queryResponse = QueryResponse( + command_id: commandIDForResponse, + success: actualResponse.error == nil, + command: commandEnvelope.command.rawValue, + handlerResponse: actualResponse, + debug_logs: finalDebugLogs + ) + + do { + let data = try encoder.encode(queryResponse) + fputs("[axorc DEBUG] QueryResponse encoded to data. Size: \(data.count)\n", stderr) + if let str = String(data: data, encoding: .utf8) { + fputs("[axorc DEBUG] QueryResponse data converted to string. Length: \(str.count). Printing to stdout.\n", stderr) + print(str) // STDOUT + } else { + fputs("[axorc DEBUG] Failed to convert QueryResponse data to UTF8 string.\n", stderr) + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Failed to convert QueryResponse data to string (UTF8)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + fputs("[axorc DEBUG] Explicitly CAUGHT error during QueryResponse encoding for getAttributes: \(error)\n", stderr) + fputs("[axorc DEBUG] Error localizedDescription: \(error.localizedDescription)\n", stderr) + if let encodingError = error as? EncodingError { + fputs("[axorc DEBUG] EncodingError context: \(encodingError)\n", stderr) + } + + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Caught error during QueryResponse encoding: \(error.localizedDescription)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + case .query: + guard let locatorForHandler = commandEnvelope.locator else { + let errorMsg = "query command requires a locator but none was provided" + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + let axInstance = AXorcist() + var handlerLogs = currentLogs + + let commandIDForResponse = commandEnvelope.command_id + let appIdentifierForHandler = commandEnvelope.application + let requestedAttributesForHandler = commandEnvelope.attributes + let pathHintForHandler = commandEnvelope.path_hint + let maxDepthForHandler = commandEnvelope.max_elements + let outputFormatForHandler = commandEnvelope.output_format + + // Call the new handleQuery method + let operationResult: HandlerResponse = await axInstance.handleQuery( + for: appIdentifierForHandler, + locator: locatorForHandler, + pathHint: pathHintForHandler, + maxDepth: maxDepthForHandler, + requestedAttributes: requestedAttributesForHandler, + outputFormat: outputFormatForHandler, + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, + currentDebugLogs: &handlerLogs + ) + + let actualResponse = operationResult + let finalDebugLogs = debug || (commandEnvelope.debug_logging ?? false) ? handlerLogs : nil + + fputs("[axorc DEBUG] Attempting to encode QueryResponse for query...\n", stderr) + let queryResponse = QueryResponse( + command_id: commandIDForResponse, + success: actualResponse.error == nil, + command: commandEnvelope.command.rawValue, + handlerResponse: actualResponse, + debug_logs: finalDebugLogs + ) + + do { + let data = try encoder.encode(queryResponse) + fputs("[axorc DEBUG] QueryResponse encoded to data. Size: \(data.count)\n", stderr) + if let str = String(data: data, encoding: .utf8) { + fputs("[axorc DEBUG] QueryResponse data converted to string. Length: \(str.count). Printing to stdout.\n", stderr) + print(str) // STDOUT + } else { + fputs("[axorc DEBUG] Failed to convert QueryResponse data to UTF8 string.\n", stderr) + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Failed to convert QueryResponse data to string (UTF8)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + fputs("[axorc DEBUG] Explicitly CAUGHT error during QueryResponse encoding for query: \(error)\n", stderr) + fputs("[axorc DEBUG] Error localizedDescription: \(error.localizedDescription)\n", stderr) + if let encodingError = error as? EncodingError { + fputs("[axorc DEBUG] EncodingError context: \(encodingError)\n", stderr) + } + + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Caught error during QueryResponse encoding: \(error.localizedDescription)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + case .describeElement: + guard let locatorForHandler = commandEnvelope.locator else { + let errorMsg = "describeElement command requires a locator but none was provided" + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + let axInstance = AXorcist() + var handlerLogs = currentLogs + + let commandIDForResponse = commandEnvelope.command_id + let appIdentifierForHandler = commandEnvelope.application + let pathHintForHandler = commandEnvelope.path_hint + let maxDepthForHandler = commandEnvelope.max_elements + let outputFormatForHandler = commandEnvelope.output_format + + // Call the new handleDescribeElement method + let operationResult: HandlerResponse = await axInstance.handleDescribeElement( + for: appIdentifierForHandler, + locator: locatorForHandler, + pathHint: pathHintForHandler, + maxDepth: maxDepthForHandler, + outputFormat: outputFormatForHandler, + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, + currentDebugLogs: &handlerLogs + ) + + let actualResponse = operationResult + let finalDebugLogs = debug || (commandEnvelope.debug_logging ?? false) ? handlerLogs : nil + + fputs("[axorc DEBUG] Attempting to encode QueryResponse for describeElement...\n", stderr) + let queryResponse = QueryResponse( + command_id: commandIDForResponse, + success: actualResponse.error == nil, + command: commandEnvelope.command.rawValue, + handlerResponse: actualResponse, + debug_logs: finalDebugLogs + ) + + do { + let data = try encoder.encode(queryResponse) + fputs("[axorc DEBUG] QueryResponse encoded to data. Size: \(data.count)\n", stderr) + if let str = String(data: data, encoding: .utf8) { + fputs("[axorc DEBUG] QueryResponse data converted to string. Length: \(str.count). Printing to stdout.\n", stderr) + print(str) // STDOUT + } else { + fputs("[axorc DEBUG] Failed to convert QueryResponse data to UTF8 string.\n", stderr) + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Failed to convert QueryResponse data to string (UTF8)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + fputs("[axorc DEBUG] Explicitly CAUGHT error during QueryResponse encoding for describeElement: \(error)\n", stderr) + fputs("[axorc DEBUG] Error localizedDescription: \(error.localizedDescription)\n", stderr) + if let encodingError = error as? EncodingError { + fputs("[axorc DEBUG] EncodingError context: \(encodingError)\n", stderr) + } + + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Caught error during QueryResponse encoding: \(error.localizedDescription)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + case .performAction: + guard let locatorForHandler = commandEnvelope.locator else { + let errorMsg = "performAction command requires a locator but none was provided" + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + guard let actionNameForHandler = commandEnvelope.action_name else { + let errorMsg = "performAction command requires an action_name but none was provided" + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + let axInstance = AXorcist() + var handlerLogs = currentLogs + + let commandIDForResponse = commandEnvelope.command_id + let appIdentifierForHandler = commandEnvelope.application + let pathHintForHandler = commandEnvelope.path_hint + let actionValueForHandler = commandEnvelope.action_value // This is AnyCodable? + + // Call the new handlePerformAction method + let operationResult: HandlerResponse = await axInstance.handlePerformAction( + for: appIdentifierForHandler, + locator: locatorForHandler, + pathHint: pathHintForHandler, + actionName: actionNameForHandler, + actionValue: actionValueForHandler, + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, + currentDebugLogs: &handlerLogs + ) + + let actualResponse = operationResult + let finalDebugLogs = debug || (commandEnvelope.debug_logging ?? false) ? handlerLogs : nil + + fputs("[axorc DEBUG] Attempting to encode QueryResponse for performAction...\n", stderr) + let queryResponse = QueryResponse( + command_id: commandIDForResponse, + success: actualResponse.error == nil, + command: commandEnvelope.command.rawValue, + handlerResponse: actualResponse, + debug_logs: finalDebugLogs + ) + + do { + let data = try encoder.encode(queryResponse) + fputs("[axorc DEBUG] QueryResponse encoded to data. Size: \(data.count)\n", stderr) + if let str = String(data: data, encoding: .utf8) { + fputs("[axorc DEBUG] QueryResponse data converted to string. Length: \(str.count). Printing to stdout.\n", stderr) + print(str) // STDOUT + } else { + fputs("[axorc DEBUG] Failed to convert QueryResponse data to UTF8 string.\n", stderr) + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Failed to convert QueryResponse data to string (UTF8)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + fputs("[axorc DEBUG] Explicitly CAUGHT error during QueryResponse encoding for performAction: \(error)\n", stderr) + fputs("[axorc DEBUG] Error localizedDescription: \(error.localizedDescription)\n", stderr) + if let encodingError = error as? EncodingError { + fputs("[axorc DEBUG] EncodingError context: \(encodingError)\n", stderr) + } + + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Caught error during QueryResponse encoding: \(error.localizedDescription)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + case .extractText: + guard let locatorForHandler = commandEnvelope.locator else { + let errorMsg = "extractText command requires a locator but none was provided" + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + let axInstance = AXorcist() + var handlerLogs = currentLogs + + let commandIDForResponse = commandEnvelope.command_id + let appIdentifierForHandler = commandEnvelope.application + let pathHintForHandler = commandEnvelope.path_hint + + let operationResult: HandlerResponse = await axInstance.handleExtractText( + for: appIdentifierForHandler, + locator: locatorForHandler, + pathHint: pathHintForHandler, + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, + currentDebugLogs: &handlerLogs + ) + + let actualResponse = operationResult + let finalDebugLogs = debug || (commandEnvelope.debug_logging ?? false) ? handlerLogs : nil + + fputs("[axorc DEBUG] Attempting to encode QueryResponse for extractText...\n", stderr) + let queryResponse = QueryResponse( + command_id: commandIDForResponse, + success: actualResponse.error == nil, + command: commandEnvelope.command.rawValue, + handlerResponse: actualResponse, + debug_logs: finalDebugLogs + ) + + do { + let data = try encoder.encode(queryResponse) + fputs("[axorc DEBUG] QueryResponse encoded to data. Size: \(data.count)\n", stderr) + if let str = String(data: data, encoding: .utf8) { + fputs("[axorc DEBUG] QueryResponse data converted to string. Length: \(str.count). Printing to stdout.\n", stderr) + print(str) // STDOUT + } else { + fputs("[axorc DEBUG] Failed to convert QueryResponse data to UTF8 string.\n", stderr) + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Failed to convert QueryResponse data to string (UTF8)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + fputs("[axorc DEBUG] Explicitly CAUGHT error during QueryResponse encoding for extractText: \(error)\n", stderr) + fputs("[axorc DEBUG] Error localizedDescription: \(error.localizedDescription)\n", stderr) + if let encodingError = error as? EncodingError { + fputs("[axorc DEBUG] EncodingError context: \(encodingError)\n", stderr) + } + + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Caught error during QueryResponse encoding: \(error.localizedDescription)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + case .batch: + // The main commandEnvelope is for the batch itself. + // Sub-commands are now directly in commandEnvelope.sub_commands. + guard let subCommands = commandEnvelope.sub_commands, !subCommands.isEmpty else { + let errorMsg = "Batch command received, but 'sub_commands' array is missing or empty." + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + return + } + + currentLogs.append("Processing batch command. Batch ID: \(commandEnvelope.command_id), Number of sub-commands: \(subCommands.count)") + + let axInstance = AXorcist() + var handlerLogs = currentLogs // batch handler will append to this + + // Call the handleBatchCommands method + let batchHandlerResponses: [HandlerResponse] = await axInstance.handleBatchCommands( + batchCommandID: commandEnvelope.command_id, // Use the main command's ID for the batch + subCommands: subCommands, // Pass the array of CommandEnvelopes + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, // Use overall debug flag + currentDebugLogs: &handlerLogs + ) + + // Convert each HandlerResponse into a QueryResponse + var batchQueryResponses: [QueryResponse] = [] + var overallSuccess = true + for (index, subHandlerResponse) in batchHandlerResponses.enumerated() { + // The subCommandEnvelope for ID and type. + // Make sure subCommands array is not empty and index is valid. + guard index < subCommands.count else { + // This should not happen if batchHandlerResponses lines up with subCommands + let errorMsg = "Mismatch between subCommands and batchHandlerResponses count." + currentLogs.append(errorMsg) + // Consider how to report this internal error + continue + } + let subCommandEnvelope = subCommands[index] + + let subQueryResponse = QueryResponse( + command_id: subCommandEnvelope.command_id, // Use sub-command's ID + success: subHandlerResponse.error == nil, + command: subCommandEnvelope.command.rawValue, // Use sub-command's type + handlerResponse: subHandlerResponse, + debug_logs: nil // Individual sub-command logs are part of HandlerResponse. + // QueryResponse's init handles this for its 'error' or 'data'. + // The overall batch debug log will be separate. + ) + batchQueryResponses.append(subQueryResponse) + if subHandlerResponse.error != nil { + overallSuccess = false + } + } + + let finalDebugLogsForBatch = debug || (commandEnvelope.debug_logging ?? false) ? handlerLogs : nil + + let batchOperationResponse = BatchOperationResponse( + command_id: commandEnvelope.command_id, // ID of the overall batch from the main envelope + success: overallSuccess, + results: batchQueryResponses, + debug_logs: finalDebugLogsForBatch + ) + + do { + let data = try encoder.encode(batchOperationResponse) + if let str = String(data: data, encoding: .utf8) { + print(str) + } else { + let errorMsg = "Failed to convert BatchOperationResponse to UTF8 string." + currentLogs.append(errorMsg) // Log to main logs + fputs("[axorc DEBUG] \(errorMsg)\n", stderr) + // Fallback to a simple error if top-level encoding fails + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: finalDebugLogsForBatch) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + let errorMsg = "Failed to encode BatchOperationResponse: \(error.localizedDescription)" + currentLogs.append(errorMsg) // Log to main logs + fputs("[axorc DEBUG] \(errorMsg) - Error: \(error)\n", stderr) + // Fallback to a simple error + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: finalDebugLogsForBatch) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + case .collectAll: + let axInstance = AXorcist() + let handlerLogs = currentLogs // Changed var to let + + let commandIDForResponse = commandEnvelope.command_id + let appIdentifierForHandler = commandEnvelope.application + let locatorForHandler = commandEnvelope.locator // Optional for collectAll + let pathHintForHandler = commandEnvelope.path_hint + let maxDepthForHandler = commandEnvelope.max_elements + let requestedAttributesForHandler = commandEnvelope.attributes + let outputFormatForHandler = commandEnvelope.output_format + + // Call handleCollectAll, passing handlerLogs as non-inout + let operationResult: HandlerResponse = await axInstance.handleCollectAll( + for: appIdentifierForHandler, + locator: locatorForHandler, + pathHint: pathHintForHandler, + maxDepth: maxDepthForHandler, + requestedAttributes: requestedAttributesForHandler, + outputFormat: outputFormatForHandler, + isDebugLoggingEnabled: commandEnvelope.debug_logging ?? debug, + currentDebugLogs: handlerLogs // Pass as [String] + ) + + // operationResult.debug_logs now contains all logs from the handler + // including the initial handlerLogs plus anything new from handleCollectAll. + let finalDebugLogs = (debug || (commandEnvelope.debug_logging ?? false)) ? operationResult.debug_logs : nil + + fputs("[axorc DEBUG] Attempting to encode QueryResponse for collectAll...\n", stderr) + let queryResponse = QueryResponse( + command_id: commandIDForResponse, + success: operationResult.error == nil, + command: commandEnvelope.command.rawValue, + handlerResponse: operationResult, + debug_logs: finalDebugLogs + ) + + do { + let data = try encoder.encode(queryResponse) + fputs("[axorc DEBUG] QueryResponse encoded to data. Size: \(data.count)\n", stderr) + if let str = String(data: data, encoding: .utf8) { + fputs("[axorc DEBUG] QueryResponse data converted to string. Length: \(str.count). Printing to stdout.\n", stderr) + print(str) // STDOUT + } else { + fputs("[axorc DEBUG] Failed to convert QueryResponse data to UTF8 string.\n", stderr) + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Failed to convert QueryResponse data to string (UTF8)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let errData = try? encoder.encode(errResponse), let errStr = String(data: errData, encoding: .utf8) { print(errStr) } + } + } catch { + fputs("[axorc DEBUG] Explicitly CAUGHT error during QueryResponse encoding for collectAll: \(error)\n", stderr) + fputs("[axorc DEBUG] Error localizedDescription: \(error.localizedDescription)\n", stderr) + if let encodingError = error as? EncodingError { + fputs("[axorc DEBUG] EncodingError context: \(encodingError)\n", stderr) + } + + let errorDetailForResponse = ErrorResponse.ErrorDetail(message: "Caught error during QueryResponse encoding: \(error.localizedDescription)") + let errResponse = ErrorResponse(command_id: commandIDForResponse, error: errorDetailForResponse, debug_logs: finalDebugLogs) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + + default: + let errorMsg = "Unhandled command type: \(commandEnvelope.command)" + currentLogs.append(errorMsg) + let errResponse = ErrorResponse(command_id: commandEnvelope.command_id, error: ErrorResponse.ErrorDetail(message: errorMsg), debug_logs: debug ? currentLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + } catch { + var errorLogs = localDebugLogs + let basicErrorMessage = "JSON decoding error: \(error.localizedDescription)" + errorLogs.append(basicErrorMessage) + + let detailedErrorMessage: String + if let decodingError = error as? DecodingError { + errorLogs.append("Decoding error details: \(decodingError.humanReadableDescription)") + detailedErrorMessage = "Failed to decode JSON command (DecodingError): \(decodingError.humanReadableDescription)" + } else { + detailedErrorMessage = "Failed to decode JSON command: \(error.localizedDescription)" + } + + let errResponse = ErrorResponse(command_id: "decode_error", error: ErrorResponse.ErrorDetail(message: detailedErrorMessage), debug_logs: debug ? errorLogs : nil) + if let data = try? encoder.encode(errResponse), let str = String(data: data, encoding: .utf8) { print(str) } + } + } +} + +// MARK: - Codable Structs for axorc responses and CommandEnvelope +// These should align with structs in AXorcistIntegrationTests.swift + +struct SimpleSuccessResponse: Codable { + let command_id: String + let success: Bool + let status: String? // e.g., "pong" + let message: String + let details: String? + let debug_logs: [String]? +} + +struct ErrorResponse: Codable { + let command_id: String + var success: Bool = false // Default to false for errors + struct ErrorDetail: Codable { + let message: String + } + let error: ErrorDetail + let debug_logs: [String]? +} + +// AXElement as received from AXorcist library and to be encoded in QueryResponse +// This is a pass-through structure. AXorcist.AXElement should be Codable itself. +// If AXorcist.AXElement is not Codable, then this needs to be manually constructed. +// For now, assume AXorcist.AXElement is Codable or can be easily made so. +// The properties (attributes, path) must match what AXorcist.AXElement provides. +struct AXElementForEncoding: Codable { + let attributes: [String: AnyCodable]? // This will now use AXorcist.AnyCodable + let path: [String]? + + init(from axElement: AXElement) { // axElement is AXorcist.AXElement + self.attributes = axElement.attributes // Directly assign + self.path = axElement.path + } +} + +struct QueryResponse: Codable { + let command_id: String + 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 debug_logs: [String]? + + // Custom initializer to bridge from HandlerResponse (from AXorcist module) + init(command_id: String, success: Bool, command: String, handlerResponse: HandlerResponse, debug_logs: [String]?) { + self.command_id = command_id + self.success = success + self.command = command + if let axElement = handlerResponse.data { + self.data = AXElementForEncoding(from: axElement) // Convert here + } else { + self.data = nil + } + if let errorMsg = handlerResponse.error { + self.error = ErrorResponse.ErrorDetail(message: errorMsg) + } else { + self.error = nil + } + self.debug_logs = debug_logs + } +} + +struct BatchOperationResponse: Codable { + let command_id: String + let success: Bool + let results: [QueryResponse] + let debug_logs: [String]? +} + +// Helper for DecodingError display +extension DecodingError { + var humanReadableDescription: String { + switch self { + case .typeMismatch(let type, let context): return "Type mismatch for \(type): \(context.debugDescription) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))" + case .valueNotFound(let type, let context): return "Value not found for \(type): \(context.debugDescription) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))" + case .keyNotFound(let key, let context): return "Key not found: \(key.stringValue) at \(context.codingPath.map { $0.stringValue }.joined(separator: ".")) - \(context.debugDescription)" + case .dataCorrupted(let context): return "Data corrupted: \(context.debugDescription) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))" + @unknown default: return self.localizedDescription + } + } +} + +/* +struct AXORC: ParsableCommand { ... old content ... } +*/ + diff --git a/Tests/AXorcistTests/AXorcistIntegrationTests.swift b/Tests/AXorcistTests/AXorcistIntegrationTests.swift new file mode 100644 index 0000000..225f845 --- /dev/null +++ b/Tests/AXorcistTests/AXorcistIntegrationTests.swift @@ -0,0 +1,1252 @@ +import AppKit +import XCTest +import Testing +@testable import AXorcist + +// Refactored TextEdit setup logic into an @MainActor async function +@MainActor +private func setupTextEditAndGetInfo() async throws -> (pid: pid_t, axAppElement: AXUIElement?) { + let textEditBundleId = "com.apple.TextEdit" + var app: NSRunningApplication? = NSRunningApplication.runningApplications(withBundleIdentifier: textEditBundleId).first + + if app == nil { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: textEditBundleId) else { + throw TestError.generic("Could not find URL for TextEdit application.") + } + + print("Attempting to launch TextEdit from URL: \(url.path)") + // Use the older launchApplication API which sometimes is more robust in test environments + // despite deprecation. Configure for async and no activation initially. + let configuration: [NSWorkspace.LaunchConfigurationKey: Any] = [:] // Empty config for older API + do { + app = try NSWorkspace.shared.launchApplication(at: url, + options: [.async, .withoutActivation], + configuration: configuration) + print("launchApplication call completed. App PID if returned: \(app?.processIdentifier ?? -1)") + } catch { + throw TestError.appNotRunning("Failed to launch TextEdit using launchApplication(at:options:configuration:): \(error.localizedDescription)") + } + + // Wait for the app to appear in running applications list + var launchedApp: NSRunningApplication? = nil + for attempt in 1...10 { // Retry for up to 10 * 0.5s = 5 seconds + launchedApp = NSRunningApplication.runningApplications(withBundleIdentifier: textEditBundleId).first + if launchedApp != nil { + print("TextEdit found running after launch, attempt \(attempt).") + break + } + try await Task.sleep(for: .milliseconds(500)) + print("Waiting for TextEdit to appear in running list... attempt \(attempt)") + } + + guard let runningAppAfterLaunch = launchedApp else { + throw TestError.appNotRunning("TextEdit did not appear in running applications list after launch attempt.") + } + app = runningAppAfterLaunch // Assign the found app + } + + guard let runningApp = app else { + // This should be redundant now due to the guard above, but as a final safety. + throw TestError.appNotRunning("TextEdit is unexpectedly nil before activation checks.") + } + + let pid = runningApp.processIdentifier + let axAppElement = AXUIElementCreateApplication(pid) + + // Activate and ensure a window + if !runningApp.isActive { + runningApp.activate(options: [.activateAllWindows]) + try await Task.sleep(for: .seconds(1.5)) // Wait for activation + } + + var window: AnyObject? + let resultCopyAttribute = AXUIElementCopyAttributeValue(axAppElement, ApplicationServices.kAXWindowsAttribute as CFString, &window) + if resultCopyAttribute != AXError.success || (window as? [AXUIElement])?.isEmpty ?? true { + let appleScript = """ + tell application "System Events" + tell process "TextEdit" + set frontmost to true + keystroke "n" using command down + end tell + end tell + """ + var errorDict: NSDictionary? + if let scriptObject = NSAppleScript(source: appleScript) { + scriptObject.executeAndReturnError(&errorDict) + if let error = errorDict { + throw TestError.appleScriptError("Failed to create new document in TextEdit: \(error)") + } + try await Task.sleep(for: .seconds(2)) // Wait for new document window + } + } + + // Re-check activation + if !runningApp.isActive { + runningApp.activate(options: [.activateAllWindows]) + try await Task.sleep(for: .seconds(1)) + } + + // Optional: Confirm focused element directly (for debugging setup) + var cfFocusedElement: CFTypeRef? + let status = AXUIElementCopyAttributeValue(axAppElement, ApplicationServices.kAXFocusedUIElementAttribute as CFString, &cfFocusedElement) + if status == AXError.success, cfFocusedElement != nil { + print("AX API successfully got a focused element during setup.") + } else { + print("AX API did not get a focused element during setup. Status: \(status.rawValue). This might be okay.") + } + + return (pid, axAppElement) +} + +@MainActor +private func closeTextEdit() async { + let textEditBundleId = "com.apple.TextEdit" + guard let textEdit = NSRunningApplication.runningApplications(withBundleIdentifier: textEditBundleId).first else { + return // Not running + } + + textEdit.terminate() + // Give it a moment to terminate gracefully + for _ in 0..<5 { // Check for up to 2.5 seconds + if textEdit.isTerminated { break } + try? await Task.sleep(for: .milliseconds(500)) + } + + if !textEdit.isTerminated { + textEdit.forceTerminate() + try? await Task.sleep(for: .milliseconds(500)) // Brief pause after force terminate + } +} + +private func runAXORCCommand(arguments: [String]) throws -> (String?, String?, Int32) { + let axorcUrl = productsDirectory.appendingPathComponent("axorc") + + let process = Process() + process.executableURL = axorcUrl + process.arguments = arguments + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + try process.run() + process.waitUntilExit() + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + + let output = String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + let errorOutput = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + + // Strip the AXORC_JSON_OUTPUT_PREFIX if present + let cleanOutput = stripJSONPrefix(from: output) + + return (cleanOutput, errorOutput, process.terminationStatus) +} + +// Helper to create a temporary file with content +private func createTempFile(content: String) throws -> String { + let tempDir = FileManager.default.temporaryDirectory + let fileName = UUID().uuidString + ".json" + let fileURL = tempDir.appendingPathComponent(fileName) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL.path +} + +// Helper to strip the JSON output prefix from axorc output +private func stripJSONPrefix(from output: String?) -> String? { + guard let output = output else { return nil } + let prefix = "AXORC_JSON_OUTPUT_PREFIX:::" + if output.hasPrefix(prefix) { + return String(output.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return output +} + +// Function to run axorc with STDIN input +private func runAXORCCommandWithStdin(inputJSON: String, arguments: [String]) throws -> (String?, String?, Int32) { + let axorcUrl = productsDirectory.appendingPathComponent("axorc") + + let process = Process() + process.executableURL = axorcUrl + // Ensure --stdin is included if not already present, as axorc.swift now uses it as a flag + var effectiveArguments = arguments + if !effectiveArguments.contains("--stdin") { + effectiveArguments.append("--stdin") + } + process.arguments = effectiveArguments + + let outputPipe = Pipe() + let errorPipe = Pipe() + let inputPipe = Pipe() + + process.standardOutput = outputPipe + process.standardError = errorPipe + process.standardInput = inputPipe + + try process.run() + + // Write to STDIN + if let inputData = inputJSON.data(using: .utf8) { + try inputPipe.fileHandleForWriting.write(contentsOf: inputData) + inputPipe.fileHandleForWriting.closeFile() // Close STDIN to signal EOF + } else { + // Handle error: inputJSON could not be converted to Data + inputPipe.fileHandleForWriting.closeFile() // Still close it + // Consider throwing an error or logging + print("Warning: Could not convert inputJSON to Data for STDIN.") + } + + process.waitUntilExit() + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + + let output = String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + let errorOutput = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + + let cleanOutput = stripJSONPrefix(from: output) + + return (cleanOutput, errorOutput, process.terminationStatus) +} + +// MARK: - Codable Structs for Testing + +// Based on axorc.swift and AXorcist.swift +enum CommandType: String, Codable { + case ping + case getFocusedElement + // Add other command types as they are implemented in axorc + case collectAll, query, describeElement, getAttributes, performAction, extractText, batch +} + +// Local test model for Locator, mirroring AXorcist.Locator from Models.swift +struct Locator: Codable { + var match_all: Bool? + var criteria: [String: String] + var root_element_path_hint: [String]? + var requireAction: String? // Snake case for JSON: require_action + var computed_name_contains: String? + + enum CodingKeys: String, CodingKey { + case match_all + case criteria + case root_element_path_hint + case requireAction = "require_action" + case computed_name_contains + } + + init(match_all: Bool? = nil, criteria: [String: String] = [:], root_element_path_hint: [String]? = nil, requireAction: String? = nil, computed_name_contains: String? = nil) { + self.match_all = match_all + self.criteria = criteria + self.root_element_path_hint = root_element_path_hint + self.requireAction = requireAction + self.computed_name_contains = computed_name_contains + } +} + +struct CommandEnvelope: Codable { + let command_id: String + let command: CommandType + let application: String? + let attributes: [String]? + let debug_logging: Bool? + + // Use the locally defined Locator struct that mirrors AXorcist.Locator + let locator: Locator? + let path_hint: [String]? // Changed from String? to [String]? to align with AXorcist.CommandEnvelope + let max_elements: Int? + let output_format: OutputFormat? // Use directly from AXorcist module (OutputFormat, not AXorcist.OutputFormat) + let action_name: String? + let action_value: AnyCodable? // Use directly from AXorcist module (AnyCodable, not AXorcist.AnyCodable) + + let payload: [String: AnyCodable]? // Use directly from AXorcist module + let sub_commands: [CommandEnvelope]? // Recursive for batch command + + init(command_id: String, + command: CommandType, + application: String? = nil, + attributes: [String]? = nil, + debug_logging: Bool? = nil, + locator: Locator? = nil, // Use local Locator type + path_hint: [String]? = nil, // Aligned to [String]? + max_elements: Int? = nil, + output_format: OutputFormat? = nil, // Use direct OutputFormat + action_name: String? = nil, + action_value: AnyCodable? = nil, // Use direct AnyCodable + payload: [String: AnyCodable]? = nil, // Use direct AnyCodable + sub_commands: [CommandEnvelope]? = nil + ) { + self.command_id = command_id + self.command = command + self.application = application + self.attributes = attributes + self.debug_logging = debug_logging + self.locator = locator + self.path_hint = path_hint + self.max_elements = max_elements + self.output_format = output_format + self.action_name = action_name + self.action_value = action_value + self.payload = payload + self.sub_commands = sub_commands + } +} + +// Matches SimpleSuccessResponse implicitly defined in axorc.swift for ping +struct SimpleSuccessResponse: Codable { + let command_id: String + let success: Bool // Assuming true for success responses + let status: String? // e.g., "pong" + let message: String + let details: String? + let debug_logs: [String]? + + // Adding an explicit init to match how it might be constructed if `success` is always true for this type + init(command_id: String, success: Bool = true, status: String?, message: String, details: String?, debug_logs: [String]?) { + self.command_id = command_id + self.success = success + self.status = status + self.message = message + self.details = details + self.debug_logs = debug_logs + } +} + +// Matches ErrorResponse implicitly defined in axorc.swift +struct ErrorResponse: Codable { + let command_id: String + let success: Bool // Assuming false for error responses + let error: ErrorDetail // Changed from String to ErrorDetail struct + + struct ErrorDetail: Codable { // Nested struct for error message + let message: String + } + let debug_logs: [String]? + + // Custom init if needed, for now relying on synthesized one after struct change + init(command_id: String, success: Bool = false, error: ErrorDetail, debug_logs: [String]?) { + self.command_id = command_id + self.success = success + self.error = error + self.debug_logs = debug_logs + } +} + + +// For AXElement.attributes which can be [String: Any] +// Using a simplified AnyCodable for testing purposes + + +struct AXElementData: Codable { // Renamed from AXElement to avoid conflict if AXorcist.AXElement is imported + let attributes: [String: AnyCodable]? // Dictionary of attributes using AnyCodable from AXorcist module + let path: [String]? // Optional path from root + // Add other fields like role, description if they become part of the AXElement structure in axorc output + + // Explicit init to allow nil for attributes and path + init(attributes: [String: AnyCodable]? = nil, path: [String]? = nil) { // Use direct AnyCodable + self.attributes = attributes + self.path = path + } +} + +// Matches QueryResponse implicitly defined in axorc.swift for getFocusedElement +struct QueryResponse: Codable { + let command_id: String + let success: Bool + let command: String // e.g., "getFocusedElement" + let data: AXElementData? // This will contain the AX element's data + let error: ErrorDetail? // Changed from String? + let debug_logs: [String]? +} + +// Added for batch command testing +struct BatchOperationResponse: Codable { + let command_id: String + let success: Bool + let results: [QueryResponse] // Assuming batch results are QueryResponses + let debug_logs: [String]? +} + + +// MARK: - Test Cases + +@Test("Test Ping via STDIN") +func testPingViaStdin() async throws { + let inputJSON = """ + { + "command_id": "test_ping_stdin", + "command": "ping", + "payload": { + "message": "Hello from testPingViaStdin" + } + } + """ + let (output, errorOutput, terminationStatus) = try runAXORCCommandWithStdin(inputJSON: inputJSON, arguments: ["--stdin"]) + + #expect(terminationStatus == 0, "axorc command failed with status \(terminationStatus). Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "Expected no error output, but got: \(errorOutput!)") + + guard let outputString = output else { + #expect(Bool(false), "Output was nil for ping via STDIN") + return + } + + guard let responseData = outputString.data(using: .utf8) else { + #expect(Bool(false), "Failed to convert output to Data for ping via STDIN. Output: \(outputString)") + return + } + let decodedResponse = try JSONDecoder().decode(SimpleSuccessResponse.self, from: responseData) + #expect(decodedResponse.success == true) + #expect(decodedResponse.message == "Ping handled by AXORCCommand. Input source: STDIN", "Unexpected success message: \(decodedResponse.message)") + #expect(decodedResponse.details == "Hello from testPingViaStdin") +} + +@Test("Test Ping via --file") +func testPingViaFile() async throws { + let payloadMessage = "Hello from testPingViaFile" + let inputJSON = """ + { + "command_id": "test_ping_file", + "command": "ping", + "payload": { "message": "\(payloadMessage)" } + } + """ + let tempFilePath = try createTempFile(content: inputJSON) + defer { try? FileManager.default.removeItem(atPath: tempFilePath) } + + // axorc needs --file flag + let (output, errorOutput, terminationStatus) = try runAXORCCommand(arguments: ["--file", tempFilePath]) + + #expect(terminationStatus == 0, "axorc command failed with status \(terminationStatus). Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "Expected no error output, but got: \(errorOutput ?? "N/A")") + + guard let outputString = output else { + #expect(Bool(false), "Output was nil for ping via file") + return + } + guard let responseData = outputString.data(using: .utf8) else { + #expect(Bool(false), "Failed to convert output to Data for ping via file. Output: \(outputString)") + return + } + // Use the updated SimpleSuccessResponse for decoding + let decodedResponse = try JSONDecoder().decode(SimpleSuccessResponse.self, from: responseData) + #expect(decodedResponse.success == true) + #expect(decodedResponse.message.lowercased().contains("file: \(tempFilePath.lowercased())"), "Message should contain file path. Got: \(decodedResponse.message)") + #expect(decodedResponse.details == payloadMessage) +} + + +@Test("Test Ping via direct positional argument") +func testPingViaDirectPayload() async throws { + let payloadMessage = "Hello from testPingViaDirectPayload" + // Ensure the JSON string is compact and valid for a command-line argument + let inputJSON = "{\"command_id\":\"test_ping_direct\",\"command\":\"ping\",\"payload\":{\"message\":\"\(payloadMessage)\"}}" + + let (output, errorOutput, terminationStatus) = try runAXORCCommand(arguments: [inputJSON]) // No --stdin or --file for direct + + #expect(terminationStatus == 0, "axorc command failed with status \(terminationStatus). Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "Expected no error output, but got: \(errorOutput ?? "N/A")") + + guard let outputString = output else { + #expect(Bool(false), "Output was nil for ping via direct payload") + return + } + guard let responseData = outputString.data(using: .utf8) else { + #expect(Bool(false), "Failed to convert output to Data for ping via direct payload. Output: \(outputString)") + return + } + let decodedResponse = try JSONDecoder().decode(SimpleSuccessResponse.self, from: responseData) + #expect(decodedResponse.success == true) + #expect(decodedResponse.message.contains("Direct Argument Payload"), "Unexpected success message: \(decodedResponse.message)") + #expect(decodedResponse.details == payloadMessage) +} + +@Test("Test Error: Multiple Input Methods (stdin and file)") +func testErrorMultipleInputMethods() async throws { + let inputJSON = """ + { + "command_id": "test_error_multiple_inputs", + "command": "ping", + "payload": { "message": "This should not be processed" } + } + """ + let tempFilePath = try createTempFile(content: "{}") // Empty JSON for file + defer { try? FileManager.default.removeItem(atPath: tempFilePath) } + + // Pass arguments that trigger multiple inputs, including --stdin for runAXORCCommandWithStdin + let (output, errorOutput, terminationStatus) = try runAXORCCommandWithStdin(inputJSON: inputJSON, arguments: ["--file", tempFilePath]) // --stdin is added by the helper + + // axorc.swift now prints error to STDOUT and exits 0 + #expect(terminationStatus == 0, "axorc command should return 0 with error on stdout. Status: \(terminationStatus). Error STDOUT: \(output ?? "nil"). Error STDERR: \(errorOutput ?? "nil")") + + guard let outputString = output, !outputString.isEmpty else { + #expect(Bool(false), "Output was nil or empty for multiple input methods error test") + return + } + guard let responseData = outputString.data(using: .utf8) else { + #expect(Bool(false), "Failed to convert output to Data for multiple input methods error. Output: \(outputString)") + return + } + // Use the updated ErrorResponse for decoding + let errorResponse = try JSONDecoder().decode(ErrorResponse.self, from: responseData) + #expect(errorResponse.success == false) + #expect(errorResponse.error.message.contains("Multiple input flags specified"), "Unexpected error message: \(errorResponse.error.message)") +} + + +@Test("Test Error: No Input Provided for Ping") +func testErrorNoInputProvidedForPing() async throws { + // Run axorc with no input flags or direct payload + let (output, errorOutput, terminationStatus) = try runAXORCCommand(arguments: []) + + #expect(terminationStatus == 0, "axorc should return 0 with error on stdout. Status: \(terminationStatus). Error STDOUT: \(output ?? "nil"). Error STDERR: \(errorOutput ?? "nil")") + + guard let outputString = output, !outputString.isEmpty else { + #expect(Bool(false), "Output was nil or empty for no input test.") + return + } + guard let responseData = outputString.data(using: .utf8) else { + #expect(Bool(false), "Failed to convert output to Data for no input error. Output: \(outputString)") + return + } + let errorResponse = try JSONDecoder().decode(ErrorResponse.self, from: responseData) + #expect(errorResponse.success == false) + #expect(errorResponse.command_id == "input_error", "Expected command_id to be input_error, got \(errorResponse.command_id)") + #expect(errorResponse.error.message.contains("No JSON input method specified"), "Unexpected error message for no input: \(errorResponse.error.message)") +} + +// The original failing test, now adapted +@Test("Launch TextEdit, Get Focused Element via STDIN") +func testLaunchAndQueryTextEdit() async throws { + // Close TextEdit if it's running from a previous test + await closeTextEdit() // Now async and @MainActor + try await Task.sleep(for: .milliseconds(500)) // Pause after closing + + // Setup TextEdit (launch, activate, ensure window) - this is @MainActor + let (pid, _) = try await setupTextEditAndGetInfo() + #expect(pid != 0, "PID should not be zero after TextEdit setup") + // axAppElement from setupTextEditAndGetInfo is not directly used hereafter, but setup ensures app is ready. + + // Prepare the JSON command for axorc + let commandId = "focused_textedit_test_\(UUID().uuidString)" + let attributesToFetch: [String] = [ + ApplicationServices.kAXRoleAttribute as String, + ApplicationServices.kAXRoleDescriptionAttribute as String, + ApplicationServices.kAXValueAttribute as String, + "AXPlaceholderValue" // Custom attribute + ] + + let commandEnvelope = CommandEnvelope( + command_id: commandId, + command: .getFocusedElement, + application: "com.apple.TextEdit", + attributes: attributesToFetch, + debug_logging: true, + locator: nil, // Explicitly nil if not used for this command, or provide actual locator + payload: nil // Ensure all params of init are present or defaulted + ) + + let encoder = JSONEncoder() + let inputJSONData = try encoder.encode(commandEnvelope) + guard let inputJSON = String(data: inputJSONData, encoding: .utf8) else { + throw TestError.generic("Failed to encode CommandEnvelope to JSON string") + } + + print("Input JSON for axorc:\n\(inputJSON)") + + let (output, errorOutput, terminationStatus) = try runAXORCCommandWithStdin(inputJSON: inputJSON, arguments: ["--debug"]) + + print("axorc STDOUT:\n\(output ?? "nil")") + print("axorc STDERR:\n\(errorOutput ?? "nil")") + print("axorc Termination Status: \(terminationStatus)") + + #expect(terminationStatus == 0, "axorc command failed with status \(terminationStatus). Error Output: \(errorOutput ?? "N/A")") + + guard let outputJSONString = output else { + throw TestError.generic("axorc output was nil or empty for getFocusedElement. STDERR: \(errorOutput ?? "N/A")") + } + + let decoder = JSONDecoder() + guard let responseData = outputJSONString.data(using: .utf8) else { + throw TestError.generic("Failed to convert axorc output string to Data for getFocusedElement. Output: \(outputJSONString)") + } + + let queryResponse: QueryResponse + do { + queryResponse = try decoder.decode(QueryResponse.self, from: responseData) + } catch { + print("JSON Decoding Error: \(error)") + print("Problematic JSON string from axorc: \(outputJSONString)") // Print the problematic JSON + throw TestError.generic("Failed to decode QueryResponse from axorc: \(error.localizedDescription). Original JSON: \(outputJSONString)") + } + + #expect(queryResponse.success == true, "axorc command was not successful. Error: \(queryResponse.error?.message ?? "Unknown error"). Logs: \(queryResponse.debug_logs?.joined(separator: "\n") ?? "")") + #expect(queryResponse.command_id == commandId) + #expect(queryResponse.command == CommandType.getFocusedElement.rawValue) // Compare with rawValue + + guard let elementData = queryResponse.data else { + throw TestError.generic("QueryResponse data is nil. Error: \(queryResponse.error?.message ?? "N/A"). Logs: \(queryResponse.debug_logs?.joined(separator: "\n") ?? "")") + } + + // Validate attributes (example) + // Cast kAXTextAreaRole (CFString) to String for comparison + // Use ApplicationServices for standard AX constants + let expectedRole = ApplicationServices.kAXTextAreaRole as String + let actualRole = elementData.attributes?[ApplicationServices.kAXRoleAttribute as String]?.value as? String + #expect(actualRole == expectedRole, "Focused element role should be '\(expectedRole)'. Got: '\(actualRole ?? "nil")'. Attributes: \(elementData.attributes?.keys.map { $0 } ?? [])") + + // Use ApplicationServices.kAXValueAttribute and cast to String for key + #expect(elementData.attributes?.keys.contains(ApplicationServices.kAXValueAttribute as String) == true, "Focused element attributes should contain kAXValueAttribute as it was requested.") + + if let logs = queryResponse.debug_logs, !logs.isEmpty { + print("axorc Debug Logs:") + logs.forEach { print($0) } + } + + // Clean up TextEdit + await closeTextEdit() // Now async and @MainActor +} + +@Test("Get Attributes for TextEdit Application") +@MainActor +func testGetAttributesForTextEditApplication() async throws { + let commandId = "getattributes-textedit-app-\(UUID().uuidString)" + let textEditBundleId = "com.apple.TextEdit" + let requestedAttributes = ["AXRole", "AXTitle", "AXWindows", "AXFocusedWindow", "AXMainWindow", "AXIdentifier"] + + // Ensure TextEdit is running + do { + _ = try await setupTextEditAndGetInfo() + print("TextEdit setup completed for getAttributes test.") + } catch { + throw TestError.generic("TextEdit setup failed for getAttributes: \(error.localizedDescription)") + } + defer { + Task { await closeTextEdit() } + print("TextEdit close process initiated for getAttributes test.") + } + + // For getAttributes on the application itself + let appLocator = Locator(criteria: [:]) // Empty criteria, or specify if known e.g. ["AXRole": "AXApplication"] + + let commandEnvelope = CommandEnvelope( + command_id: commandId, + command: .getAttributes, + application: textEditBundleId, + attributes: requestedAttributes, + debug_logging: true, + locator: appLocator // Specify the locator for the application + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let jsonData = try encoder.encode(commandEnvelope) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON string for getAttributes command.") + } + + print("Sending getAttributes command to axorc: \(jsonString)") + let (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [jsonString]) + + #expect(exitCode == 0, "axorc process should exit with 0. Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "STDERR should be empty on success. Got: \(errorOutput ?? "")") + + guard let outputString = output, !outputString.isEmpty else { + throw TestError.generic("Output string was nil or empty for getAttributes.") + } + print("Received output from axorc (getAttributes): \(outputString)") + + guard let responseData = outputString.data(using: .utf8) else { + throw TestError.generic("Could not convert output string to data for getAttributes. Output: \(outputString)") + } + + let decoder = JSONDecoder() + do { + let queryResponse = try decoder.decode(QueryResponse.self, from: responseData) + + #expect(queryResponse.command_id == commandId) + #expect(queryResponse.success == true, "getAttributes command should succeed. Error: \(queryResponse.error?.message ?? "None")") + #expect(queryResponse.command == CommandType.getAttributes.rawValue) + #expect(queryResponse.error == nil, "Error field should be nil. Got: \(queryResponse.error?.message ?? "N/A")") + #expect(queryResponse.data != nil, "Data field should not be nil.") + #expect(queryResponse.data?.attributes != nil, "AXElement attributes should not be nil.") + + // Check some specific attributes + let attributes = queryResponse.data?.attributes + #expect(attributes?["AXRole"]?.value as? String == "AXApplication", "Application role should be AXApplication. Got: \(String(describing: attributes?["AXRole"]?.value))") + #expect(attributes?["AXTitle"]?.value as? String == "TextEdit", "Application title should be TextEdit. Got: \(String(describing: attributes?["AXTitle"]?.value))") + + // AXWindows should be an array + if let windowsAttr = attributes?["AXWindows"] { + #expect(windowsAttr.value is [Any], "AXWindows should be an array. Type: \(type(of: windowsAttr.value))") + if let windowsArray = windowsAttr.value as? [AnyCodable] { + #expect(!windowsArray.isEmpty, "AXWindows array should not be empty if TextEdit has windows.") + } else if let windowsArray = windowsAttr.value as? [Any] { // More general check + #expect(!windowsArray.isEmpty, "AXWindows array should not be empty (general type check).") + } + } else { + #expect(attributes?["AXWindows"] != nil, "AXWindows attribute should be present.") + } + + #expect(queryResponse.debug_logs != nil, "Debug logs should be present.") + #expect(queryResponse.debug_logs?.contains { $0.contains("Handling getAttributes command") || $0.contains("handleGetAttributes completed") } == true, "Debug logs should indicate getAttributes execution.") + + } catch { + throw TestError.generic("Failed to decode QueryResponse for getAttributes: \(error.localizedDescription). Original JSON: \(outputString)") + } +} + +@Test("Query for TextEdit Text Area") +@MainActor +func testQueryForTextEditTextArea() async throws { + let commandId = "query-textedit-textarea-\(UUID().uuidString)" + let textEditBundleId = "com.apple.TextEdit" + // Use kAXTextAreaRole from ApplicationServices for accuracy + let textAreaRole = ApplicationServices.kAXTextAreaRole as String + let requestedAttributes = ["AXRole", "AXValue", "AXSelectedText", "AXNumberOfCharacters"] + + // Ensure TextEdit is running and has a window + do { + _ = try await setupTextEditAndGetInfo() + print("TextEdit setup completed for query test.") + } catch { + throw TestError.generic("TextEdit setup failed for query: \(error.localizedDescription)") + } + defer { + Task { await closeTextEdit() } + print("TextEdit close process initiated for query test.") + } + + // Locator to find the first text area in TextEdit + let textAreaLocator = Locator( + criteria: ["AXRole": textAreaRole] + ) + + let commandEnvelope = CommandEnvelope( + command_id: commandId, + command: .query, + application: textEditBundleId, + attributes: requestedAttributes, + debug_logging: true, + locator: textAreaLocator + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let jsonData = try encoder.encode(commandEnvelope) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON string for query command.") + } + + print("Sending query command to axorc: \(jsonString)") + let (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [jsonString]) + + #expect(exitCode == 0, "axorc process should exit with 0. Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "STDERR should be empty on success. Got: \(errorOutput ?? "")") + + guard let outputString = output, !outputString.isEmpty else { + throw TestError.generic("Output string was nil or empty for query.") + } + print("Received output from axorc (query): \(outputString)") + + guard let responseData = outputString.data(using: .utf8) else { + throw TestError.generic("Could not convert output string to data for query. Output: \(outputString)") + } + + let decoder = JSONDecoder() + do { + let queryResponse = try decoder.decode(QueryResponse.self, from: responseData) + + #expect(queryResponse.command_id == commandId) + #expect(queryResponse.success == true, "query command should succeed. Error: \(queryResponse.error?.message ?? "None")") + #expect(queryResponse.command == CommandType.query.rawValue) + #expect(queryResponse.error == nil, "Error field should be nil. Got: \(queryResponse.error?.message ?? "N/A")") + #expect(queryResponse.data != nil, "Data field should not be nil.") + #expect(queryResponse.data?.attributes != nil, "AXElement attributes should not be nil.") + + let attributes = queryResponse.data?.attributes + #expect(attributes?["AXRole"]?.value as? String == textAreaRole, "Element role should be \(textAreaRole). Got: \(String(describing: attributes?["AXRole"]?.value))") + + // AXValue might be an empty string if the new document is empty, which is fine. + #expect(attributes?["AXValue"]?.value is String, "AXValue should exist and be a string.") + #expect(attributes?["AXNumberOfCharacters"]?.value is Int, "AXNumberOfCharacters should exist and be an Int.") + + #expect(queryResponse.debug_logs != nil, "Debug logs should be present.") + #expect(queryResponse.debug_logs?.contains { $0.contains("Handling query command") || $0.contains("handleQuery completed") } == true, "Debug logs should indicate query execution.") + + } catch { + throw TestError.generic("Failed to decode QueryResponse for query: \(error.localizedDescription). Original JSON: \(outputString)") + } +} + +@Test("Describe TextEdit Text Area") +@MainActor +func testDescribeTextEditTextArea() async throws { + let commandId = "describe-textedit-textarea-\(UUID().uuidString)" + let textEditBundleId = "com.apple.TextEdit" + let textAreaRole = ApplicationServices.kAXTextAreaRole as String + + // Ensure TextEdit is running and has a window + do { + _ = try await setupTextEditAndGetInfo() + print("TextEdit setup completed for describeElement test.") + } catch { + throw TestError.generic("TextEdit setup failed for describeElement: \(error.localizedDescription)") + } + defer { + Task { await closeTextEdit() } + print("TextEdit close process initiated for describeElement test.") + } + + // Locator to find the first text area in TextEdit + let textAreaLocator = Locator( + criteria: ["AXRole": textAreaRole] + ) + + let commandEnvelope = CommandEnvelope( + command_id: commandId, + command: .describeElement, + application: textEditBundleId, + // No attributes explicitly requested for describeElement + debug_logging: true, + locator: textAreaLocator + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let jsonData = try encoder.encode(commandEnvelope) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON string for describeElement command.") + } + + print("Sending describeElement command to axorc: \(jsonString)") + let (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [jsonString]) + + #expect(exitCode == 0, "axorc process should exit with 0. Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "STDERR should be empty on success. Got: \(errorOutput ?? "")") + + guard let outputString = output, !outputString.isEmpty else { + throw TestError.generic("Output string was nil or empty for describeElement.") + } + print("Received output from axorc (describeElement): \(outputString)") + + guard let responseData = outputString.data(using: .utf8) else { + throw TestError.generic("Could not convert output string to data for describeElement. Output: \(outputString)") + } + + let decoder = JSONDecoder() + do { + let queryResponse = try decoder.decode(QueryResponse.self, from: responseData) + + #expect(queryResponse.command_id == commandId) + #expect(queryResponse.success == true, "describeElement command should succeed. Error: \(queryResponse.error?.message ?? "None")") + #expect(queryResponse.command == CommandType.describeElement.rawValue) + #expect(queryResponse.error == nil, "Error field should be nil. Got: \(queryResponse.error?.message ?? "N/A")") + #expect(queryResponse.data != nil, "Data field should not be nil.") + + guard let attributes = queryResponse.data?.attributes else { + throw TestError.generic("Attributes dictionary is nil in describeElement response.") + } + + #expect(attributes["AXRole"]?.value as? String == textAreaRole, "Element role should be \(textAreaRole). Got: \(String(describing: attributes["AXRole"]?.value))") + + // describeElement should return many attributes. Check for a few common ones. + #expect(attributes["AXRoleDescription"]?.value is String, "AXRoleDescription should exist.") + #expect(attributes["AXEnabled"]?.value is Bool, "AXEnabled should exist.") + #expect(attributes["AXPosition"]?.value != nil, "AXPosition should exist.") // Value can be complex (e.g., AXValue containing a CGPoint) + #expect(attributes["AXSize"]?.value != nil, "AXSize should exist.") // Value can be complex (e.g., AXValue containing a CGSize) + #expect(attributes.count > 10, "Expected describeElement to return many attributes (e.g., > 10). Got \(attributes.count)") + + #expect(queryResponse.debug_logs != nil, "Debug logs should be present.") + #expect(queryResponse.debug_logs?.contains { $0.contains("Handling describeElement command") || $0.contains("handleDescribeElement completed") } == true, "Debug logs should indicate describeElement execution.") + + } catch { + throw TestError.generic("Failed to decode QueryResponse for describeElement: \(error.localizedDescription). Original JSON: \(outputString)") + } +} + +@Test("Perform Action: Set Value of TextEdit Text Area") +@MainActor +func testPerformActionSetTextEditTextAreaValue() async throws { + let actionCommandId = "performaction-setvalue-\(UUID().uuidString)" + let queryCommandId = "query-verify-setvalue-\(UUID().uuidString)" + let textEditBundleId = "com.apple.TextEdit" + let textAreaRole = ApplicationServices.kAXTextAreaRole as String + let textToSet = "Hello from AXORC performAction test! Time: \(Date())" + + // Ensure TextEdit is running and has a window + do { + _ = try await setupTextEditAndGetInfo() + print("TextEdit setup completed for performAction test.") + } catch { + throw TestError.generic("TextEdit setup failed for performAction: \(error.localizedDescription)") + } + defer { + Task { await closeTextEdit() } + print("TextEdit close process initiated for performAction test.") + } + + // Locator for the text area + let textAreaLocator = Locator( + criteria: ["AXRole": textAreaRole] + ) + + // 1. Perform AXSetValueAction + let performActionEnvelope = CommandEnvelope( + command_id: actionCommandId, + command: .performAction, + application: textEditBundleId, + debug_logging: true, + locator: textAreaLocator, + action_name: "AXSetValue", // Standard action for setting value + action_value: AnyCodable(textToSet) // AXorcist.AnyCodable wrapping the string + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + var jsonData = try encoder.encode(performActionEnvelope) + guard var jsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON for performAction command.") + } + + print("Sending performAction (AXSetValue) command: \(jsonString)") + var (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [jsonString]) + + #expect(exitCode == 0, "performAction axorc call failed. Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "STDERR for performAction should be empty. Got: \(errorOutput ?? "")") + + guard let actionOutputString = output, !actionOutputString.isEmpty else { + throw TestError.generic("Output for performAction was nil/empty.") + } + print("Received output from performAction: \(actionOutputString)") + guard let actionResponseData = actionOutputString.data(using: .utf8) else { + throw TestError.generic("Could not convert performAction output to data. Output: \(actionOutputString)") + } + + let decoder = JSONDecoder() + do { + let actionResponse = try decoder.decode(QueryResponse.self, from: actionResponseData) // performAction returns a QueryResponse + #expect(actionResponse.command_id == actionCommandId) + #expect(actionResponse.success == true, "performAction command was not successful. Error: \(actionResponse.error?.message ?? "N/A")") + // Some actions might not return data, but AXSetValue might confirm the element it acted upon. + // For now, primary check is success. + } catch { + throw TestError.generic("Failed to decode QueryResponse for performAction: \(error.localizedDescription). JSON: \(actionOutputString)") + } + + // Brief pause for UI to update if necessary, though AXSetValue is often synchronous. + try await Task.sleep(for: .milliseconds(100)) + + // 2. Query the AXValue to verify + let queryEnvelope = CommandEnvelope( + command_id: queryCommandId, + command: .query, + application: textEditBundleId, + attributes: ["AXValue"], // Only need AXValue + debug_logging: true, + locator: textAreaLocator + ) + + jsonData = try encoder.encode(queryEnvelope) + guard let queryJsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON for query (verify) command.") + } + + print("Sending query (to verify AXSetValue) command: \(queryJsonString)") + (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [queryJsonString]) + + #expect(exitCode == 0, "Query (verify) axorc call failed. Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "STDERR for query (verify) should be empty. Got: \(errorOutput ?? "")") + + guard let queryOutputString = output, !queryOutputString.isEmpty else { + throw TestError.generic("Output for query (verify) was nil/empty.") + } + print("Received output from query (verify): \(queryOutputString)") + guard let queryResponseData = queryOutputString.data(using: .utf8) else { + throw TestError.generic("Could not convert query (verify) output to data. Output: \(queryOutputString)") + } + + do { + let verifyResponse = try decoder.decode(QueryResponse.self, from: queryResponseData) + #expect(verifyResponse.command_id == queryCommandId) + #expect(verifyResponse.success == true, "Query (verify) command failed. Error: \(verifyResponse.error?.message ?? "N/A")") + + guard let attributes = verifyResponse.data?.attributes else { + throw TestError.generic("Attributes nil in query (verify) response.") + } + let retrievedValue = attributes["AXValue"]?.value as? String + #expect(retrievedValue == textToSet, "AXValue after AXSetValue action did not match. Expected: '\(textToSet)'. Got: '\(retrievedValue ?? "nil")'") + + #expect(verifyResponse.debug_logs != nil) + } catch { + throw TestError.generic("Failed to decode QueryResponse for query (verify): \(error.localizedDescription). JSON: \(queryOutputString)") + } +} + +@Test("Extract Text from TextEdit Text Area") +@MainActor +func testExtractTextFromTextEditTextArea() async throws { + let setValueCommandId = "setvalue-for-extract-\(UUID().uuidString)" + let extractTextCommandId = "extracttext-textedit-textarea-\(UUID().uuidString)" + let textEditBundleId = "com.apple.TextEdit" + let textAreaRole = ApplicationServices.kAXTextAreaRole as String + let textToSetAndExtract = "Text to be extracted by AXORC. Unique: \(UUID().uuidString)" + + // Ensure TextEdit is running and has a window + do { + _ = try await setupTextEditAndGetInfo() + print("TextEdit setup completed for extractText test.") + } catch { + throw TestError.generic("TextEdit setup failed for extractText: \(error.localizedDescription)") + } + defer { + Task { await closeTextEdit() } + print("TextEdit close process initiated for extractText test.") + } + + // Locator for the text area + let textAreaLocator = Locator( + criteria: ["AXRole": textAreaRole] + ) + + // 1. Set a known value in the text area using performAction + let performActionEnvelope = CommandEnvelope( + command_id: setValueCommandId, + command: .performAction, + application: textEditBundleId, + debug_logging: true, + locator: textAreaLocator, + action_name: "AXSetValue", + action_value: AnyCodable(textToSetAndExtract) + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + var jsonData = try encoder.encode(performActionEnvelope) + guard var jsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON for performAction (set value) command.") + } + + print("Sending performAction (AXSetValue) for extractText setup: \(jsonString)") + var (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [jsonString]) + + #expect(exitCode == 0, "performAction (set value) call failed. Error: \(errorOutput ?? "N/A")") + guard let actionOutputString = output, !actionOutputString.isEmpty else { throw TestError.generic("Output for performAction (set value) was nil/empty.") } + let actionResponse = try JSONDecoder().decode(QueryResponse.self, from: Data(actionOutputString.utf8)) + #expect(actionResponse.success == true, "performAction (set value) was not successful. Error: \(actionResponse.error?.message ?? "N/A")") + + try await Task.sleep(for: .milliseconds(100)) // Brief pause + + // 2. Perform extractText command + let extractTextEnvelope = CommandEnvelope( + command_id: extractTextCommandId, + command: .extractText, + application: textEditBundleId, + debug_logging: true, + locator: textAreaLocator + ) + + jsonData = try encoder.encode(extractTextEnvelope) + guard let extractJsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON for extractText command.") + } + + print("Sending extractText command: \(extractJsonString)") + (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [extractJsonString]) + + #expect(exitCode == 0, "extractText axorc call failed. Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "STDERR for extractText should be empty. Got: \(errorOutput ?? "")") + + guard let extractOutputString = output, !extractOutputString.isEmpty else { + throw TestError.generic("Output for extractText was nil/empty.") + } + print("Received output from extractText: \(extractOutputString)") + guard let extractResponseData = extractOutputString.data(using: .utf8) else { + throw TestError.generic("Could not convert extractText output to data. Output: \(extractOutputString)") + } + + let decoder = JSONDecoder() + do { + let extractQueryResponse = try decoder.decode(QueryResponse.self, from: extractResponseData) + #expect(extractQueryResponse.command_id == extractTextCommandId) + #expect(extractQueryResponse.success == true, "extractText command failed. Error: \(extractQueryResponse.error?.message ?? "N/A")") + #expect(extractQueryResponse.command == CommandType.extractText.rawValue) + + guard let attributes = extractQueryResponse.data?.attributes else { + throw TestError.generic("Attributes nil in extractText response.") + } + + // AXorcist.handleExtractText is expected to return the text. + // The most straightforward way for it to appear in QueryResponse is via an attribute in `data.attributes`. + // Common attribute for text content is AXValue. Let's assume extractText populates this or a specific "ExtractedText" attribute. + // For now, checking AXValue as it's the most standard for text areas. + let extractedValue = attributes["AXValue"]?.value as? String + #expect(extractedValue == textToSetAndExtract, "Extracted text did not match set text. Expected: '\(textToSetAndExtract)'. Got: '\(extractedValue ?? "nil")'") + + #expect(extractQueryResponse.debug_logs != nil) + #expect(extractQueryResponse.debug_logs?.contains { $0.contains("Handling extractText command") || $0.contains("handleExtractText completed") } == true, "Debug logs should indicate extractText execution.") + + } catch { + throw TestError.generic("Failed to decode QueryResponse for extractText: \(error.localizedDescription). JSON: \(extractOutputString)") + } +} + +@Test("Batch Command: GetFocusedElement and Query TextEdit") +@MainActor +func testBatchCommand_GetFocusedElementAndQuery() async throws { + let batchCommandId = "batch-textedit-\(UUID().uuidString)" + let focusedElementSubCmdId = "batch-sub-getfocused-\(UUID().uuidString)" + let querySubCmdId = "batch-sub-querytextarea-\(UUID().uuidString)" + let textEditBundleId = "com.apple.TextEdit" + let textAreaRole = ApplicationServices.kAXTextAreaRole as String + + // Ensure TextEdit is running and has a window + do { + _ = try await setupTextEditAndGetInfo() + print("TextEdit setup completed for batch command test.") + } catch { + throw TestError.generic("TextEdit setup failed for batch command: \(error.localizedDescription)") + } + defer { + Task { await closeTextEdit() } + print("TextEdit close process initiated for batch command test.") + } + + // Sub-command 1: Get Focused Element + let getFocusedElementSubCommand = CommandEnvelope( + command_id: focusedElementSubCmdId, + command: .getFocusedElement, + application: textEditBundleId, + debug_logging: true + ) + + // Sub-command 2: Query for Text Area + let queryTextAreaSubCommandLocator = Locator(criteria: ["AXRole": textAreaRole]) + let queryTextAreaSubCommand = CommandEnvelope( + command_id: querySubCmdId, + command: .query, + application: textEditBundleId, + attributes: ["AXRole", "AXValue"], // Request some attributes for the text area + debug_logging: true, + locator: queryTextAreaSubCommandLocator + ) + + // Main Batch Command + let batchCommandEnvelope = CommandEnvelope( + command_id: batchCommandId, + command: .batch, + application: nil, // Application context is per sub-command if needed + debug_logging: true, + sub_commands: [getFocusedElementSubCommand, queryTextAreaSubCommand] + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted // Easier to debug JSON if needed + let jsonData = try encoder.encode(batchCommandEnvelope) + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw TestError.generic("Failed to create JSON string for batch command.") + } + + print("Sending batch command to axorc: \(jsonString)") + let (output, errorOutput, exitCode) = try runAXORCCommand(arguments: [jsonString]) + + #expect(exitCode == 0, "axorc process for batch command should exit with 0. Error: \(errorOutput ?? "N/A")") + #expect(errorOutput == nil || errorOutput!.isEmpty, "STDERR for batch command should be empty on success. Got: \(errorOutput ?? "")") + + guard let outputString = output, !outputString.isEmpty else { + throw TestError.generic("Output string was nil or empty for batch command.") + } + print("Received output from axorc (batch command): \(outputString)") + + guard let responseData = outputString.data(using: .utf8) else { + throw TestError.generic("Could not convert output string to data for batch command. Output: \(outputString)") + } + + let decoder = JSONDecoder() + do { + let batchResponse = try decoder.decode(BatchOperationResponse.self, from: responseData) + + #expect(batchResponse.command_id == batchCommandId) + #expect(batchResponse.success == true, "Batch command overall should succeed. Error: \(batchResponse.results.first(where: { !$0.success })?.error?.message ?? "None")") + #expect(batchResponse.results.count == 2, "Expected 2 results in batch response, got \(batchResponse.results.count)") + + // Check first sub-command result (getFocusedElement) + let result1 = batchResponse.results[0] + #expect(result1.command_id == focusedElementSubCmdId) + #expect(result1.success == true, "Sub-command getFocusedElement failed. Error: \(result1.error?.message ?? "N/A")") + #expect(result1.command == CommandType.getFocusedElement.rawValue) + #expect(result1.data != nil, "Data for getFocusedElement should not be nil") + #expect(result1.data?.attributes?["AXRole"]?.value as? String == textAreaRole, "Focused element (from batch) should be text area. Got \(String(describing: result1.data?.attributes?["AXRole"]?.value))") + + // Check second sub-command result (query for text area) + let result2 = batchResponse.results[1] + #expect(result2.command_id == querySubCmdId) + #expect(result2.success == true, "Sub-command query text area failed. Error: \(result2.error?.message ?? "N/A")") + #expect(result2.command == CommandType.query.rawValue) + #expect(result2.data != nil, "Data for query text area should not be nil") + #expect(result2.data?.attributes?["AXRole"]?.value as? String == textAreaRole, "Queried element (from batch) should be text area. Got \(String(describing: result2.data?.attributes?["AXRole"]?.value))") + + #expect(batchResponse.debug_logs != nil, "Batch response debug logs should be present.") + #expect(batchResponse.debug_logs?.contains { $0.contains("Executing batch command") || $0.contains("Batch command processing completed") } == true, "Debug logs should indicate batch execution.") + + } catch { + throw TestError.generic("Failed to decode BatchOperationResponse: \(error.localizedDescription). Original JSON: \(outputString)") + } +} + +// TestError enum definition +enum TestError: Error, CustomStringConvertible { + case appNotRunning(String) + case axError(String) + case appleScriptError(String) + case generic(String) + + var description: String { + switch self { + case .appNotRunning(let s): return "AppNotRunning: \(s)" + case .axError(let s): return "AXError: \(s)" + case .appleScriptError(let s): return "AppleScriptError: \(s)" + case .generic(let s): return "GenericTestError: \(s)" + } + } +} + +// Products directory helper (if not already present from previous steps) +var productsDirectory: URL { + #if os(macOS) + // First, try the .xctest bundle method (works well in Xcode) + for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { + return bundle.bundleURL.deletingLastPathComponent() + } + + // Fallback for SPM command-line tests if .xctest bundle isn't found as expected. + // This navigates up from the test file to the package root, then to .build/debug. + let currentFileURL = URL(fileURLWithPath: #filePath) + // Assuming Tests/AXorcistTests/AXorcistIntegrationTests.swift structure: + // currentFileURL.deletingLastPathComponent() // AXorcistTests directory + // .deletingLastPathComponent() // Tests directory + // .deletingLastPathComponent() // AXorcist package root directory + let packageRootPath = currentFileURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + + // Try common build paths for SwiftPM + let buildPathsToTry = [ + packageRootPath.appendingPathComponent(".build/debug"), + packageRootPath.appendingPathComponent(".build/arm64-apple-macosx/debug"), + packageRootPath.appendingPathComponent(".build/x86_64-apple-macosx/debug") + ] + + let fileManager = FileManager.default + for path in buildPathsToTry { + // Check if the directory exists and contains the axorc executable + if fileManager.fileExists(atPath: path.appendingPathComponent("axorc").path) { + return path + } + } + + fatalError("couldn\'t find the products directory via Bundle or SPM fallback. Package root guessed as: \(packageRootPath.path). Searched paths: \(buildPathsToTry.map { $0.path }.joined(separator: ", "))") + #else + return Bundle.main.bundleURL + #endif +} \ No newline at end of file diff --git a/Tests/AXorcistTests/SimpleXCTest.swift b/Tests/AXorcistTests/SimpleXCTest.swift new file mode 100644 index 0000000..749d5b3 --- /dev/null +++ b/Tests/AXorcistTests/SimpleXCTest.swift @@ -0,0 +1,11 @@ +import XCTest + +class SimpleXCTest: XCTestCase { + func testExample() throws { + XCTAssertEqual(1, 1, "Simple assertion should pass") + } + + func testAnotherExample() { + XCTAssertTrue(true, "Another simple assertion") + } +} \ No newline at end of file diff --git a/axorc b/axorc new file mode 100755 index 0000000..d49bc10 Binary files /dev/null and b/axorc differ diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..56d15a8 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +echo "=== AXorcist Test Runner ===" +echo "Killing any existing SwiftPM processes..." + +# Kill any existing swift processes +pkill -f "swift" || true +pkill -f "SourceKitService" || true + +echo "Starting swift test (without git clean to preserve dependencies)..." +swift test \ No newline at end of file