Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 58 additions & 11 deletions Sources/AXSwift6/UIElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import os
/// Note that every operation involves IPC and is tied to the event loop of the target process. This
/// means that operations are synchronous and can hang until they time out. The default timeout is
/// 6 seconds, but it can be changed using `setMessagingTimeout(_:)` or the deprecated
/// `globalMessagingTimeout` property.
/// `globalMessagingTimeout` property. Those apply to a single native handle; to bound every
/// element the library creates, set `UIElement.defaultMessagingTimeout` once at startup.
///
/// Every attribute- or action-related function has an enum version and a String version. This is
/// because certain processes might report attributes or actions not documented in the standard API.
Expand Down Expand Up @@ -86,6 +87,36 @@ public final class UIElement: Sendable {

private let storage: Storage

private static let defaultMessagingTimeoutStorage = OSAllocatedUnfairLock<Float>(
initialState: 0
)

/// The messaging timeout, in seconds, applied to each element as it is created.
///
/// Every accessibility operation is synchronous IPC tied to the event loop of the
/// target process, so a wedged or busy target blocks the caller until the message
/// times out. The system default is 6 seconds, which is far longer than a
/// user-facing operation can afford to wait.
///
/// Setting a per-element timeout is the only way to bound that wait, but
/// ``setMessagingTimeout(_:)`` applies to a single native handle — it does not
/// propagate to elements obtained later from that one, because each is a distinct
/// native reference. Setting this property instead applies the timeout at
/// ``init(_:)``, which every element funnels through, including children returned
/// by attribute lookups.
///
/// The default is `0`, meaning the system default is used and behavior is
/// unchanged. Set this once, before creating elements, to opt in.
///
/// - note: Only newly created elements are affected. An element whose native
/// reference is already known reuses its existing storage and keeps the
/// timeout it already has, so changing this value does not retroactively
/// retime live elements.
public static var defaultMessagingTimeout: Float {
get { defaultMessagingTimeoutStorage.withLock { $0 } }
set { defaultMessagingTimeoutStorage.withLock { $0 = max(newValue, 0) } }
}

/// Create a UIElement from a raw AXUIElement object.
///
/// The state and role of the AXUIElement is not checked.
Expand All @@ -94,19 +125,35 @@ public final class UIElement: Sendable {
assert(CFGetTypeID(nativeElement) == AXUIElementGetTypeID(),
"nativeElement is not an AXUIElement")

// Read before taking the registry lock: never hold two locks at once.
let defaultTimeout = Self.defaultMessagingTimeout

let key = ObjectIdentifier(nativeElement)
let candidate = Storage(element: nativeElement)
storage = Self.storageRegistry.withLock { registry in
if let storage = registry.entries[key]?.value {
return storage
}
registry.entries[key] = WeakStorage(candidate)
registry.insertionsSinceCleanup += 1
if registry.insertionsSinceCleanup >= 256 {
registry.entries = registry.entries.filter { $0.value.value != nil }
registry.insertionsSinceCleanup = 0
let (resolvedStorage, isNewStorage) = Self.storageRegistry
.withLock { registry -> (Storage, Bool) in
if let storage = registry.entries[key]?.value {
return (storage, false)
}
registry.entries[key] = WeakStorage(candidate)
registry.insertionsSinceCleanup += 1
if registry.insertionsSinceCleanup >= 256 {
registry.entries = registry.entries.filter { $0.value.value != nil }
registry.insertionsSinceCleanup = 0
}
return (candidate, true)
}
return candidate
storage = resolvedStorage

// Reused storage already carries its own timeout; only seed fresh storage.
guard isNewStorage, defaultTimeout > 0 else { return }
do {
try setMessagingTimeout(defaultTimeout)
} catch AXError.invalidUIElement {
// The element was already dead. This only matters once a message is
// actually sent to it, which will report the failure to that caller.
} catch {
axLog.debug("Could not apply default messaging timeout: \(error, privacy: .public)")
}
}

Expand Down
48 changes: 48 additions & 0 deletions Tests/AXSwift6Tests/DefaultMessagingTimeoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import ApplicationServices
import Testing

@testable import AXSwift6

/// These tests mutate `UIElement.defaultMessagingTimeout`, which is process-global,
/// so they must not run alongside each other.
@Suite("DefaultMessagingTimeout", .serialized)
struct DefaultMessagingTimeoutTests {
@Test("Opt-in: unset, new elements keep the system default")
func isOptIn() {
#expect(UIElement.defaultMessagingTimeout == 0)
#expect(UIElement(AXUIElementCreateApplication(getpid())).currentMessagingTimeout == 0)
}

@Test("Negative values clamp to zero")
func clampsNegativeValues() {
defer { UIElement.defaultMessagingTimeout = 0 }

UIElement.defaultMessagingTimeout = -5
#expect(UIElement.defaultMessagingTimeout == 0)
}

@Test("Applied to newly created elements")
func appliedToNewElements() {
defer { UIElement.defaultMessagingTimeout = 0 }

UIElement.defaultMessagingTimeout = 0.25
// A fresh native reference, so this cannot reuse existing storage.
let element = UIElement(AXUIElementCreateApplication(getpid()))
#expect(element.currentMessagingTimeout == 0.25)
}

@Test("Does not retime elements that already have storage")
func doesNotRetimeExistingStorage() {
defer { UIElement.defaultMessagingTimeout = 0 }

let native = AXUIElementCreateApplication(getpid())
let first = UIElement(native)
#expect(first.currentMessagingTimeout == 0)

UIElement.defaultMessagingTimeout = 0.25
// Same native reference: shares `first`'s storage, so it keeps that timeout.
let second = UIElement(native)
#expect(second.currentMessagingTimeout == 0)
#expect(first.currentMessagingTimeout == 0)
}
}