Skip to content
Closed
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
1,032 changes: 517 additions & 515 deletions libs/cua-driver/rust/crates/cua-driver-core/src/element_token.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,11 @@ pub fn element_token_schema() -> Value {
json!({
"type": "string",
"description": "Opaque per-snapshot element handle from \
`structuredContent.elements[].element_token`. Takes precedence over \
element_index when both are supplied. Returns an explicit \"stale\" \
error once a newer snapshot supersedes it — re-snapshot in that case."
`structuredContent.elements[].element_token`. On macOS click/set_value \
it is strictly bound to pid, window_id, generation, element_index, and \
AX node identity. If element_index or window_id are also supplied they \
must match. Unknown, cross-target, stale-generation, or identity-mismatch \
tokens fail closed; call get_window_state again after a stale error."
})
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -743,10 +743,10 @@ impl Tool for GetWindowStateTool {
.collect();
structured["elements"] = json!(elements);
// Surface 6: snapshot id mirror for debug correlation.
structured["snapshot_id"] =
json!(cua_driver_core::element_token::token_for(snapshot_id, 0)
.trim_end_matches(":0")
.to_string());
cua_driver_core::element_token::add_snapshot_metadata(
&mut structured,
snapshot_id,
);
structured["_note"] = json!(
"Prefer `elements` — `tree_markdown` will continue to work \
but new fields will only be added to the structured side. \
Expand Down
238 changes: 228 additions & 10 deletions libs/cua-driver/rust/crates/platform-macos/src/ax/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,21 +61,35 @@ pub struct CacheKey {

/// Cached snapshot for one (pid, window_id) pair.
pub struct CachedSnapshot {
/// element_index → raw AXUIElementRef pointer (retained, as usize for Send).
pub elements: Vec<usize>,
pub generation: u32,
/// element_index → raw AXUIElementRef pointer plus exact node identity.
pub elements: Vec<CachedElement>,
}

pub struct CachedElement {
pub ptr: usize,
pub node_identity: u64,
}

impl Drop for CachedSnapshot {
fn drop(&mut self) {
// Release the extra CFRetain that walk_element added for each cached ptr.
for ptr in &self.elements {
if *ptr != 0 {
unsafe { CFRelease(*ptr as AXUIElementRef as CFTypeRef) };
for element in &self.elements {
if element.ptr != 0 {
unsafe { CFRelease(element.ptr as AXUIElementRef as CFTypeRef) };
}
}
}
}

pub struct ValidatedElement {
pub element: RetainedElement,
pub window_id: u32,
pub element_index: usize,
pub generation: u32,
pub node_identity: u64,
}

/// Global element cache.
pub struct ElementCache {
core: ElementCacheCore<CacheKey, CachedSnapshot>,
Expand All @@ -90,13 +104,31 @@ impl ElementCache {

/// Replace the snapshot for (pid, window_id) with the nodes from a fresh walk.
pub fn update(&self, pid: i32, window_id: u32, nodes: &[AXNode]) {
let elements: Vec<usize> = nodes
self.update_with_generation(pid, window_id, 0, nodes);
}

pub fn update_with_generation(
&self,
pid: i32,
window_id: u32,
generation: u32,
nodes: &[AXNode],
) {
let elements: Vec<CachedElement> = nodes
.iter()
.filter(|n| n.element_index.is_some())
.map(|n| n.element_ptr)
.map(|n| CachedElement {
ptr: n.element_ptr,
node_identity: n.node_identity,
})
.collect();
self.core
.insert(CacheKey { pid, window_id }, CachedSnapshot { elements });
self.core.insert(
CacheKey { pid, window_id },
CachedSnapshot {
generation,
elements,
},
);
}

/// Look up + `CFRetain` the element for `element_index` in (pid, window_id),
Expand All @@ -114,7 +146,7 @@ impl ElementCache {
) -> Option<RetainedElement> {
self.core
.with_snapshot(&CacheKey { pid, window_id }, |s| {
let ptr = s.elements.get(element_index).copied()?;
let ptr = s.elements.get(element_index)?.ptr;
if ptr != 0 {
// Safety: still inside `with_snapshot`'s lock, so the
// snapshot (and thus this CFTypeRef) is alive right now.
Expand All @@ -125,6 +157,73 @@ impl ElementCache {
.flatten()
}

pub fn resolve_token(
&self,
pid: i32,
args_window_id: Option<u32>,
args_element_index: Option<usize>,
token: &str,
) -> Result<ValidatedElement, cua_driver_core::element_token::StableTokenError> {
let binding = cua_driver_core::element_token::global().resolve_stable(
pid,
args_window_id,
args_element_index,
token,
)?;
self.core
.with_snapshot(
&CacheKey {
pid,
window_id: binding.window_id,
},
|snapshot| {
if snapshot.generation != binding.generation {
return Err(
cua_driver_core::element_token::StableTokenError::stale_generation(
binding.generation,
pid,
binding.window_id,
),
);
}
let cached = snapshot
.elements
.get(binding.element_index)
.ok_or_else(|| {
cua_driver_core::element_token::StableTokenError::identity_mismatch(
binding.element_index,
)
})?;
if cached.node_identity != binding.node_identity {
return Err(
cua_driver_core::element_token::StableTokenError::identity_mismatch(
binding.element_index,
),
);
}
if cached.ptr != 0 {
unsafe { CFRetain(cached.ptr as AXUIElementRef as CFTypeRef) };
}
Ok(ValidatedElement {
element: RetainedElement(cached.ptr),
window_id: binding.window_id,
element_index: binding.element_index,
generation: binding.generation,
node_identity: binding.node_identity,
})
},
)
.unwrap_or_else(|| {
Err(
cua_driver_core::element_token::StableTokenError::stale_generation(
binding.generation,
pid,
binding.window_id,
),
)
})
}

/// Number of indexed elements for (pid, window_id), or 0 if not cached.
pub fn element_count(&self, pid: i32, window_id: u32) -> usize {
self.core
Expand Down Expand Up @@ -159,6 +258,7 @@ mod tests {
help: None,
actions: Vec::new(),
element_ptr: ptr,
node_identity: ptr as u64,
depth: 0,
parent_element_index: None,
frame: None,
Expand Down Expand Up @@ -230,4 +330,122 @@ mod tests {
cache.update(1, 2, &[]);
assert!(cache.get_element_retained(1, 2, 5).is_none());
}

#[test]
fn stable_token_resolves_only_exact_cached_ax_identity() {
let s = CFString::new("cua-driver-stable-token-exact-identity");
let ptr = s.as_concrete_TypeRef() as usize;
unsafe { CFRetain(ptr as CFTypeRef) };
let pid = 0x6afe_0001;
let window_id = 77;
let generation = cua_driver_core::element_token::global()
.register_snapshot_with_identities(pid, window_id, 1, [(0, ptr as u64)]);
let cache = ElementCache::new();
cache.update_with_generation(pid, window_id, generation, &[node_with_ptr(ptr)]);

let token = cua_driver_core::element_token::token_for(generation, 0);
let resolved = cache
.resolve_token(pid, Some(window_id), Some(0), &token)
.expect("exact identity resolves");
assert_eq!(resolved.element.as_ptr(), ptr);
assert_eq!(resolved.node_identity, ptr as u64);
}

#[test]
fn stable_token_fails_closed_on_identity_mismatch() {
let s = CFString::new("cua-driver-stable-token-identity-mismatch");
let ptr = s.as_concrete_TypeRef() as usize;
unsafe { CFRetain(ptr as CFTypeRef) };
let pid = 0x6afe_0002;
let window_id = 78;
let generation = cua_driver_core::element_token::global()
.register_snapshot_with_identities(
pid,
window_id,
1,
[(0, (ptr as u64).wrapping_add(1))],
);
let cache = ElementCache::new();
cache.update_with_generation(pid, window_id, generation, &[node_with_ptr(ptr)]);

let token = cua_driver_core::element_token::token_for(generation, 0);
let error = cache
.resolve_token(pid, Some(window_id), Some(0), &token)
.err()
.expect("identity mismatch must fail");
assert_eq!(
error.code,
cua_driver_core::element_token::TOKEN_IDENTITY_MISMATCH_CODE
);
}

#[test]
fn stable_token_fails_closed_on_cache_generation_mismatch() {
let s = CFString::new("cua-driver-stable-token-generation-mismatch");
let ptr = s.as_concrete_TypeRef() as usize;
unsafe { CFRetain(ptr as CFTypeRef) };
let pid = 0x6afe_0003;
let window_id = 79;
let generation = cua_driver_core::element_token::global()
.register_snapshot_with_identities(pid, window_id, 1, [(0, ptr as u64)]);
let cache = ElementCache::new();
cache.update_with_generation(
pid,
window_id,
generation.wrapping_add(1),
&[node_with_ptr(ptr)],
);

let token = cua_driver_core::element_token::token_for(generation, 0);
let error = cache
.resolve_token(pid, Some(window_id), Some(0), &token)
.err()
.expect("generation mismatch must fail");
assert_eq!(
error.code,
cua_driver_core::element_token::TOKEN_STALE_GENERATION_CODE
);
}

#[test]
fn superseded_snapshot_same_index_replacement_fails_closed() {
let first = CFString::new("cua-driver-stable-token-first-node");
let second = CFString::new("cua-driver-stable-token-replacement-node");
let first_ptr = first.as_concrete_TypeRef() as usize;
let second_ptr = second.as_concrete_TypeRef() as usize;
unsafe {
CFRetain(first_ptr as CFTypeRef);
CFRetain(second_ptr as CFTypeRef);
}
let pid = 0x6afe_0004;
let window_id = 80;
let first_generation = cua_driver_core::element_token::global()
.register_snapshot_with_identities(pid, window_id, 1, [(0, first_ptr as u64)]);
let cache = ElementCache::new();
cache.update_with_generation(
pid,
window_id,
first_generation,
&[node_with_ptr(first_ptr)],
);
let stale_token = cua_driver_core::element_token::token_for(first_generation, 0);

let second_generation = cua_driver_core::element_token::global()
.register_snapshot_with_identities(pid, window_id, 1, [(0, second_ptr as u64)]);
cache.update_with_generation(
pid,
window_id,
second_generation,
&[node_with_ptr(second_ptr)],
);

let error = cache
.resolve_token(pid, Some(window_id), Some(0), &stale_token)
.err()
.expect("old token must not resolve replacement node at the same index");
assert_eq!(
error.code,
cua_driver_core::element_token::TOKEN_STALE_GENERATION_CODE
);
}
}
5 changes: 5 additions & 0 deletions libs/cua-driver/rust/crates/platform-macos/src/ax/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ pub struct AXNode {
pub actions: Vec<String>,
/// The raw AXUIElementRef pointer value, for caching.
pub element_ptr: usize,
/// Snapshot-local identity of the exact AXUIElementRef represented by this
/// node. Tokens bind this value in addition to pid/window/generation/index.
pub node_identity: u64,
/// Depth in the rendered markdown tree (matches the indent level used in
/// `tree_markdown`). Layout containers AXScrollArea/AXGroup collapse so
/// children share the parent's depth.
Expand Down Expand Up @@ -453,6 +456,7 @@ unsafe fn walk_element(
help: help.clone(),
actions: actions.clone(),
element_ptr,
node_identity: element_ptr as u64,
depth,
parent_element_index: parent_index,
frame,
Expand Down Expand Up @@ -486,6 +490,7 @@ unsafe fn walk_element(
help: help.clone(),
actions: vec![],
element_ptr,
node_identity: element_ptr as u64,
depth,
parent_element_index: parent_index,
frame,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ mod tests {
help: None,
actions: actions.iter().map(|value| (*value).to_owned()).collect(),
element_ptr: 7,
node_identity: 7,
depth,
parent_element_index: None,
frame: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,7 @@ mod tests {
help: None,
actions: actions.iter().map(|value| (*value).to_owned()).collect(),
element_ptr: 7,
node_identity: 7,
depth: 0,
parent_element_index: None,
frame: None,
Expand Down
Loading
Loading