use serde::{Deserialize, Serialize};
pub const CHILD_CAP: usize = 40;
pub const DEPTH_CAP: usize = 12;
pub const LINE_CAP: usize = 400;
pub const VALUE_CAP: usize = 120;
pub const URL_PATH_CAP: usize = 60;
#[derive(Clone, Debug, PartialEq)]
pub struct UiNode {
pub role: String,
pub subrole: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub identifier: Option<String>,
pub value: Option<String>,
pub secure: bool,
pub actions: Vec<String>,
pub frame: Option<NodeFrame>,
pub child_count: Option<usize>,
pub children: Vec<UiNode>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NodeFrame {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedFrame {
pub x: i32,
pub y: i32,
pub width: u32,
pub height: u32,
}
impl From<NodeFrame> for SavedFrame {
fn from(frame: NodeFrame) -> Self {
Self {
x: round_i32(frame.x),
y: round_i32(frame.y),
width: round_u32(frame.width),
height: round_u32(frame.height),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BoundElement {
pub pid: i32,
pub window_id: u32,
pub revision: u64,
pub index: u32,
}
impl UiNode {
pub fn bare(role: impl Into<String>) -> Self {
Self {
role: role.into(),
subrole: None,
title: None,
description: None,
identifier: None,
value: None,
secure: false,
actions: Vec::new(),
frame: None,
child_count: None,
children: Vec::new(),
}
}
fn real_child_count(&self) -> usize {
self.child_count.unwrap_or(self.children.len())
}
}
pub fn find_ui(root: &UiNode, index: u32) -> Option<&UiNode> {
let mut cursor = 0u32;
fn walk<'a>(node: &'a UiNode, index: u32, cursor: &mut u32) -> Option<&'a UiNode> {
if *cursor == index {
return Some(node);
}
*cursor = cursor.saturating_add(1);
for child in &node.children {
if let Some(found) = walk(child, index, cursor) {
return Some(found);
}
}
None
}
walk(root, index, &mut cursor)
}
pub fn find_sig(root: &SigNode, index: u32) -> Option<&SigNode> {
if root.index == index {
return Some(root);
}
for child in &root.children {
if let Some(found) = find_sig(child, index) {
return Some(found);
}
}
None
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SigNode {
pub index: u32,
pub role: String,
pub identity: String,
pub child_count: usize,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<SavedFrame>,
#[serde(default, skip_serializing_if = "is_false_bool")]
pub secure: bool,
pub children: Vec<SigNode>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Rendered {
pub text: String,
pub truncated: bool,
pub root: SigNode,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FlagState {
pub manual: Option<bool>,
pub enhanced: Option<bool>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ElectronEdit {
pub set_manual: Option<bool>,
pub set_enhanced: Option<bool>,
pub restore: FlagState,
pub previous: FlagState,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TreeQuery {
pub app: Option<String>,
pub window: Option<String>,
pub full: bool,
pub session: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeReport {
pub pid: i32,
pub window_id: u32,
pub revision: u64,
pub source: String,
pub truncated: bool,
pub tree: String,
pub full_text: String,
pub diff: Option<String>,
pub app: Option<String>,
pub note: Option<String>,
pub legacy: Option<String>,
}
pub fn render(root: &UiNode) -> Rendered {
let mut lines = Vec::new();
let mut truncated = false;
let mut index = 0;
let signature = walk(root, 0, &mut index, &mut lines, &mut truncated);
if truncated {
lines.push("truncated=true".to_string());
}
let mut text = lines.join("\n");
text.push('\n');
Rendered {
text,
truncated,
root: signature,
}
}
pub fn diff_trees(previous: &SigNode, current: &SigNode) -> String {
let mut lines = Vec::new();
diff_level(
std::slice::from_ref(previous),
std::slice::from_ref(current),
&mut lines,
);
if lines.is_empty() {
String::new()
} else {
let mut text = lines.join("\n");
text.push('\n');
text
}
}
pub fn choose_text(previous: Option<&SigNode>, rendered: &Rendered, force_full: bool) -> String {
match previous {
Some(previous) if !force_full => diff_trees(previous, &rendered.root),
_ => rendered.text.clone(),
}
}
pub fn revision_of(root: &SigNode) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
hash_node(root, &mut hash);
hash
}
pub fn electron_enable(current: FlagState) -> ElectronEdit {
let set_manual = (current.manual != Some(true)).then_some(true);
let set_enhanced = (current.enhanced != Some(true)).then_some(true);
ElectronEdit {
set_manual,
set_enhanced,
restore: FlagState {
manual: restore_value(set_manual.is_some(), current.manual),
enhanced: restore_value(set_enhanced.is_some(), current.enhanced),
},
previous: current,
}
}
pub fn electron_restore(edit: &ElectronEdit) -> FlagState {
edit.restore.clone()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AttributeMap {
pub manual: Option<bool>,
pub enhanced: Option<bool>,
}
impl AttributeMap {
pub fn enable_electron(&mut self) -> ElectronEdit {
let edit = electron_enable(FlagState {
manual: self.manual,
enhanced: self.enhanced,
});
if edit.set_manual == Some(true) {
self.manual = Some(true);
}
if edit.set_enhanced == Some(true) {
self.enhanced = Some(true);
}
edit
}
pub fn restore_electron(&mut self, edit: &ElectronEdit) {
if let Some(value) = edit.restore.manual {
self.manual = Some(value);
}
if let Some(value) = edit.restore.enhanced {
self.enhanced = Some(value);
}
}
}
pub fn hosted_helper_pids(app_pid: i32, child_pids: &[i32], scanned: &[i32]) -> Vec<i32> {
let mut out = Vec::new();
push_unique(&mut out, app_pid, child_pids);
if out.is_empty() {
push_unique(&mut out, app_pid, scanned);
}
out.truncate(3);
out
}
pub fn bundle_matches_parent(
parent_name: &str,
parent_bundle: Option<&str>,
child_bundle: &str,
) -> bool {
let child = child_bundle.to_ascii_lowercase();
let squashed = parent_name.to_ascii_lowercase().replace(' ', "");
if squashed.len() >= 3 && child.contains(&squashed) {
return true;
}
let Some(parent_bundle) = parent_bundle else {
return false;
};
let parent_bundle = parent_bundle.to_ascii_lowercase();
if child != parent_bundle && child.contains(&parent_bundle) {
return true;
}
parent_bundle
.rsplit('.')
.next()
.is_some_and(|label| label.len() >= 3 && child.contains(label))
}
pub fn shorten_url(value: &str) -> String {
let (scheme, rest) = if let Some(rest) = value.strip_prefix("https://") {
("https://", rest)
} else if let Some(rest) = value.strip_prefix("http://") {
("http://", rest)
} else {
return value.to_string();
};
let rest = rest.split(['?', '#']).next().unwrap_or(rest);
let (host, path) = match rest.find('/') {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, ""),
};
format!("{scheme}{host}{}", truncate_chars(path, URL_PATH_CAP))
}
fn restore_value(changed: bool, previous: Option<bool>) -> Option<bool> {
if changed {
Some(previous.unwrap_or(false))
} else {
None
}
}
fn push_unique(out: &mut Vec<i32>, app_pid: i32, pids: &[i32]) {
for pid in pids {
if *pid == app_pid || *pid <= 0 || out.contains(pid) {
continue;
}
out.push(*pid);
if out.len() == 3 {
break;
}
}
}
fn walk(
node: &UiNode,
depth: usize,
index: &mut u32,
lines: &mut Vec<String>,
truncated: &mut bool,
) -> SigNode {
let my_index = *index;
*index += 1;
let value = display_value(node);
let identity = identity_of(node);
if lines.len() < LINE_CAP {
lines.push(format_line(node, my_index, depth, &value));
} else {
*truncated = true;
}
let total = node.real_child_count();
let mut children = Vec::new();
if depth >= DEPTH_CAP {
if total > 0 {
*truncated = true;
}
} else {
let show = total.min(CHILD_CAP).min(node.children.len());
for child in node.children.iter().take(show) {
if lines.len() >= LINE_CAP {
*truncated = true;
break;
}
children.push(walk(child, depth + 1, index, lines, truncated));
}
if total > CHILD_CAP {
*truncated = true;
if lines.len() < LINE_CAP {
let indent = " ".repeat(depth + 1);
lines.push(format!("{indent}… +{}", total - CHILD_CAP));
}
} else if node.children.len() < total {
*truncated = true;
}
}
SigNode {
index: my_index,
role: node.role.clone(),
identity,
child_count: total,
value,
frame: node.frame.map(SavedFrame::from),
secure: node.secure || is_secure(node),
children,
}
}
pub fn identity_text(node: &UiNode) -> String {
identity_of(node)
}
pub fn node_is_secure(node: &UiNode) -> bool {
node.secure || is_secure(node)
}
fn is_false_bool(value: &bool) -> bool {
!*value
}
fn format_line(node: &UiNode, index: u32, depth: usize, value: &str) -> String {
let indent = " ".repeat(depth);
let mut line = format!("{indent}[{index}] {}", node.role);
if let Some(label) = quoted_label(node) {
line.push(' ');
line.push_str("e(&label));
}
if let Some(frame) = node.frame.map(SavedFrame::from) {
line.push_str(&format!(
" @{x},{y} {width}x{height}",
x = frame.x,
y = frame.y,
width = frame.width,
height = frame.height
));
}
if node.secure || is_secure(node) {
line.push_str(" value=\"••••\"");
} else if node.value.is_some() {
line.push_str(" value=");
line.push_str("e(value));
}
if !node.actions.is_empty() {
line.push_str(" actions=");
line.push_str(&node.actions.join(","));
}
line
}
fn quoted_label(node: &UiNode) -> Option<String> {
nonempty(&node.title).or_else(|| nonempty(&node.description))
}
fn identity_of(node: &UiNode) -> String {
nonempty(&node.identifier)
.or_else(|| nonempty(&node.title))
.or_else(|| nonempty(&node.description))
.unwrap_or_default()
}
fn nonempty(value: &Option<String>) -> Option<String> {
value
.as_ref()
.map(|text| text.trim())
.filter(|text| !text.is_empty())
.map(str::to_string)
}
fn display_value(node: &UiNode) -> String {
if node.secure || is_secure(node) {
return "••••".to_string();
}
let Some(value) = node.value.as_deref() else {
return String::new();
};
truncate_chars(&shorten_url(value), VALUE_CAP)
}
fn is_secure(node: &UiNode) -> bool {
node.role == "AXSecureTextField"
|| node.role == "password text"
|| node
.subrole
.as_deref()
.is_some_and(|subrole| subrole.contains("Secure"))
}
fn quote(text: &str) -> String {
let mut out = String::from("\"");
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' | '\r' => out.push(' '),
other => out.push(other),
}
}
out.push('"');
out
}
fn round_i32(value: f64) -> i32 {
if !value.is_finite() {
return 0;
}
let clamped = value.round().clamp(i32::MIN as f64, i32::MAX as f64);
clamped as i32
}
fn round_u32(value: f64) -> u32 {
if !value.is_finite() || value <= 0.0 {
return 0;
}
let clamped = value.round().min(u32::MAX as f64);
clamped as u32
}
fn truncate_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
text.chars().take(max).collect()
}
fn diff_level(previous: &[SigNode], current: &[SigNode], lines: &mut Vec<String>) {
let pairs = lcs(previous, current);
let mut prev_index = 0;
let mut curr_index = 0;
for (left, right) in pairs {
while prev_index < left {
lines.push(removed(&previous[prev_index]));
prev_index += 1;
}
while curr_index < right {
lines.push(added(¤t[curr_index]));
curr_index += 1;
}
let before = &previous[left];
let after = ¤t[right];
if before.value != after.value {
lines.push(format!(
"~ [{}] {} value: {} -> {}",
after.index,
after.role,
quote(&before.value),
quote(&after.value)
));
}
diff_level(&before.children, &after.children, lines);
prev_index = left + 1;
curr_index = right + 1;
}
while prev_index < previous.len() {
lines.push(removed(&previous[prev_index]));
prev_index += 1;
}
while curr_index < current.len() {
lines.push(added(¤t[curr_index]));
curr_index += 1;
}
}
fn added(node: &SigNode) -> String {
format!("+ [{}] {}", node.index, labeled(node))
}
fn removed(node: &SigNode) -> String {
format!("- [{}] {}", node.index, labeled(node))
}
fn labeled(node: &SigNode) -> String {
if node.identity.is_empty() {
node.role.clone()
} else {
format!("{} {}", node.role, quote(&node.identity))
}
}
fn same_signature(left: &SigNode, right: &SigNode) -> bool {
left.role == right.role
&& left.identity == right.identity
&& left.child_count == right.child_count
}
fn lcs(previous: &[SigNode], current: &[SigNode]) -> Vec<(usize, usize)> {
let rows = previous.len() + 1;
let cols = current.len() + 1;
let mut score = vec![0u32; rows * cols];
let at = |row: usize, col: usize| row * cols + col;
for row in 1..rows {
for col in 1..cols {
if same_signature(&previous[row - 1], ¤t[col - 1]) {
score[at(row, col)] = score[at(row - 1, col - 1)] + 1;
} else {
score[at(row, col)] = score[at(row - 1, col)].max(score[at(row, col - 1)]);
}
}
}
let mut pairs = Vec::new();
let mut row = previous.len();
let mut col = current.len();
while row > 0 && col > 0 {
if same_signature(&previous[row - 1], ¤t[col - 1]) {
pairs.push((row - 1, col - 1));
row -= 1;
col -= 1;
} else if score[at(row - 1, col)] >= score[at(row, col - 1)] {
row -= 1;
} else {
col -= 1;
}
}
pairs.reverse();
pairs
}
fn hash_node(node: &SigNode, hash: &mut u64) {
hash_bytes(hash, node.role.as_bytes());
hash_bytes(hash, node.identity.as_bytes());
hash_bytes(hash, &node.child_count.to_le_bytes());
for child in &node.children {
hash_node(child, hash);
}
}
fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
for byte in bytes {
*hash ^= u64::from(*byte);
*hash = hash.wrapping_mul(0x100000001b3);
}
*hash ^= 0xff;
*hash = hash.wrapping_mul(0x100000001b3);
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(x: f64, y: f64, width: f64, height: f64) -> NodeFrame {
NodeFrame {
x,
y,
width,
height,
}
}
#[test]
fn numbering_matches_the_contract_example() {
let mut window = UiNode::bare("AXWindow");
window.title = Some("Notes".into());
window.frame = Some(frame(0.0, 0.0, 800.0, 600.0));
let mut button = UiNode::bare("AXButton");
button.title = Some("New Note".into());
button.frame = Some(frame(12.0, 10.0, 88.0, 24.0));
button.actions = vec!["AXPress".into()];
let mut text = UiNode::bare("AXTextArea");
text.frame = Some(frame(12.0, 40.0, 776.0, 500.0));
text.value = Some("hello".into());
window.children = vec![button, text];
let rendered = render(&window);
assert_eq!(
rendered.text,
"[0] AXWindow \"Notes\" @0,0 800x600\n [1] AXButton \"New Note\" @12,10 88x24 actions=AXPress\n [2] AXTextArea @12,40 776x500 value=\"hello\"\n"
);
assert!(!rendered.truncated);
assert_eq!(rendered.root.index, 0);
assert_eq!(rendered.root.children[1].index, 2);
}
#[test]
fn child_cap_depth_cap_and_line_cap_set_truncated() {
let mut parent = UiNode::bare("AXGroup");
parent.children = (0..42)
.map(|index| {
let mut child = UiNode::bare("AXStaticText");
child.title = Some(format!("n{index}"));
child
})
.collect();
parent.child_count = Some(42);
let rendered = render(&parent);
assert!(rendered.truncated);
assert!(rendered.text.contains("… +2"));
assert!(rendered.text.contains("truncated=true"));
assert_eq!(rendered.root.children.len(), 40);
assert!(rendered.text.contains("[40]"));
assert!(!rendered.text.contains("[41]"));
let mut deep = UiNode::bare("AXGroup");
let mut cursor = &mut deep;
for _ in 0..14 {
cursor.children.push(UiNode::bare("AXGroup"));
cursor = cursor.children.last_mut().unwrap();
}
let rendered = render(&deep);
assert!(rendered.truncated);
assert!(rendered
.text
.lines()
.any(|line| line.starts_with("truncated=")));
let deepest = rendered
.text
.lines()
.filter(|line| line.contains("AXGroup"))
.count();
assert_eq!(deepest, DEPTH_CAP + 1);
let mut stopped = UiNode::bare("AXGroup");
stopped.children = vec![UiNode::bare("AXStaticText"), UiNode::bare("AXStaticText")];
stopped.child_count = Some(5);
let rendered = render(&stopped);
assert!(rendered.truncated);
assert!(rendered.text.contains("truncated=true"));
assert!(
!rendered.text.contains('…'),
"a line-budget stop is not the 40-child marker"
);
let mut wide = UiNode::bare("AXGroup");
grow_binary(&mut wide, 0);
let rendered = render(&wide);
let content: Vec<_> = rendered
.text
.lines()
.filter(|line| *line != "truncated=true")
.collect();
assert_eq!(content.len(), LINE_CAP);
assert!(rendered.truncated);
}
#[test]
fn url_strip_and_secure_redaction_drop_the_secret() {
assert_eq!(
shorten_url("https://example.com/path/to/resource?q=secret"),
"https://example.com/path/to/resource"
);
let long_path = format!("/{}", "a".repeat(80));
let shortened = shorten_url(&format!("http://example.com{long_path}?x=1"));
assert_eq!(shortened, format!("http://example.com/{}", "a".repeat(59)));
assert!(!shortened.contains('?'));
let mut secret = UiNode::bare("AXSecureTextField");
secret.value = Some("hunter2".into());
secret.secure = true;
let rendered = render(&secret);
assert!(rendered.text.contains("value=\"••••\""));
assert!(!rendered.text.contains("hunter2"));
assert!(!rendered.root.value.contains("hunter2"));
let mut sub = UiNode::bare("AXTextField");
sub.subrole = Some("AXSecureTextField".into());
sub.value = Some("hunter2".into());
let rendered = render(&sub);
assert!(!rendered.text.contains("hunter2"));
assert!(rendered.text.contains("••••"));
}
#[test]
fn diff_uses_lcs_and_full_replaces_the_printed_text() {
let mut before = UiNode::bare("AXWindow");
before.identifier = Some("win".into());
before.children = vec![
labeled("AXButton", "A", "one"),
labeled("AXButton", "B", "two"),
labeled("AXTextArea", "C", "a"),
];
let mut after = UiNode::bare("AXWindow");
after.identifier = Some("win".into());
after.children = vec![
labeled("AXButton", "A", "one"),
labeled("AXTextArea", "C", "ab"),
labeled("AXButton", "D", "new"),
];
let first = render(&before);
let second = render(&after);
let diff = diff_trees(&first.root, &second.root);
assert!(diff.contains("- [2] AXButton \"B\""), "{diff}");
assert!(
diff.contains("~ [2] AXTextArea value: \"a\" -> \"ab\""),
"{diff}"
);
assert!(diff.contains("+ [3] AXButton \"D\""), "{diff}");
assert!(!diff.contains("+ [1]"));
assert_eq!(choose_text(None, &second, false), second.text);
assert_eq!(
choose_text(Some(&first.root), &second, true),
second.text,
"full replaces the baseline text"
);
let incremental = choose_text(Some(&first.root), &second, false);
assert!(incremental.starts_with('-') || incremental.starts_with('~'));
assert_ne!(revision_of(&first.root), revision_of(&second.root));
}
#[test]
fn electron_set_and_restore_returns_the_previous_values() {
let edit = electron_enable(FlagState {
manual: Some(false),
enhanced: None,
});
assert_eq!(edit.set_manual, Some(true));
assert_eq!(edit.set_enhanced, Some(true));
assert_eq!(
electron_restore(&edit),
FlagState {
manual: Some(false),
enhanced: Some(false),
}
);
let untouched = electron_enable(FlagState {
manual: Some(true),
enhanced: Some(true),
});
assert_eq!(untouched.set_manual, None);
assert_eq!(untouched.set_enhanced, None);
assert_eq!(
electron_restore(&untouched),
FlagState {
manual: None,
enhanced: None,
}
);
}
#[test]
fn attribute_map_sets_and_restores_only_changed_flags() {
let mut attrs = AttributeMap {
manual: Some(false),
enhanced: None,
};
let edit = attrs.enable_electron();
assert_eq!(
attrs,
AttributeMap {
manual: Some(true),
enhanced: Some(true),
}
);
assert_eq!(edit.previous.manual, Some(false));
assert_eq!(edit.previous.enhanced, None);
attrs.restore_electron(&edit);
assert_eq!(
attrs,
AttributeMap {
manual: Some(false),
enhanced: Some(false),
}
);
let mut already = AttributeMap {
manual: Some(true),
enhanced: Some(true),
};
let untouched = already.enable_electron();
assert_eq!(untouched.set_manual, None);
assert_eq!(untouched.set_enhanced, None);
already.manual = Some(true);
already.restore_electron(&untouched);
assert_eq!(already.manual, Some(true));
assert_eq!(already.enhanced, Some(true));
}
#[test]
fn bound_element_keeps_pid_window_revision_and_index() {
let bound = BoundElement {
pid: 412,
window_id: 88,
revision: 3,
index: 2,
};
let text = serde_json::to_string(&bound).unwrap();
let back: BoundElement = serde_json::from_str(&text).unwrap();
assert_eq!(back, bound);
}
#[test]
fn hosted_pid_walker_includes_a_foreign_child_once() {
let helpers = hosted_helper_pids(10, &[10, 77, 77, 88], &[77, 99]);
assert_eq!(helpers, vec![77, 88]);
let scanned = hosted_helper_pids(10, &[], &[10, 55, 55, 66, 67]);
assert_eq!(scanned, vec![55, 66, 67]);
assert!(bundle_matches_parent(
"Google Chrome",
Some("com.google.Chrome"),
"com.google.Chrome.helper"
));
}
fn grow_binary(node: &mut UiNode, depth: usize) {
if depth >= DEPTH_CAP {
return;
}
node.children = vec![UiNode::bare("AXGroup"), UiNode::bare("AXGroup")];
for child in &mut node.children {
grow_binary(child, depth + 1);
}
}
fn labeled(role: &str, identity: &str, value: &str) -> UiNode {
let mut node = UiNode::bare(role);
node.identifier = Some(identity.into());
node.title = Some(identity.into());
node.value = Some(value.into());
node
}
}