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
9 changes: 8 additions & 1 deletion crates/sandbox-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,19 @@ fn cmd_start(command: &str, args: &[String]) -> anyhow::Result<()> {
tauri_args.extend(args.iter().cloned());
}

tracing::info!("[start] bundle_path: {}", bundle_path.display());
tracing::info!("[start] app_binary: {}", app_binary.display());
tracing::info!("[start] tauri_args: {:?}", tauri_args);
tracing::info!("[start] app_binary exists: {}", app_binary.exists());

// Run the binary directly (not via open -a) so arguments are passed correctly
Command::new(&app_binary)
let child = Command::new(&app_binary)
.args(&tauri_args)
.spawn()
.context("Failed to launch Tauri sandbox app")?;

tracing::info!("[start] child pid: {:?}", child.id());

let full_cmd = if args.is_empty() {
command.to_string()
} else {
Expand Down
139 changes: 94 additions & 45 deletions crates/sandbox-core/src/automation/ax_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ mod macos_impl {

const K_AX_ERROR_SUCCESS: AXError = 0;

/// Minimum pointer address to be considered a valid AXUIElementRef.
/// Anything below this is almost certainly a null offset or dangling pointer.
const MIN_VALID_PTR: usize = 0x1000;

#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef;
Expand Down Expand Up @@ -73,15 +77,38 @@ mod macos_impl {
CFString::new(s)
}

/// Validate that an AXUIElementRef is usable by calling AXUIElementGetPid.
/// Returns true if the element appears valid.
unsafe fn ax_element_is_valid(element: AXUIElementRef) -> bool {
/// Safe wrapper around AXUIElementCopyAttributeValue.
/// Validates the element pointer before calling to prevent SIGSEGV.
/// Returns None if the element is invalid or the call fails.
unsafe fn ax_try_copy_value(element: AXUIElementRef, attr_name: &str) -> Option<CFTypeRef> {
if element.is_null() {
return false;
eprintln!("[ax_ui] ax_try_copy_value: null element for attr '{attr_name}'");
return None;
}
if (element as usize) < MIN_VALID_PTR {
eprintln!(
"[ax_ui] ax_try_copy_value: suspicious pointer {:#x} for attr '{attr_name}'",
element as usize
);
return None;
}
// Secondary validation via AXUIElementGetPid
let mut pid: i32 = 0;
let result = AXUIElementGetPid(element, &mut pid as *mut i32);
result == K_AX_ERROR_SUCCESS
let pid_result = AXUIElementGetPid(element, &mut pid as *mut i32);
if pid_result != K_AX_ERROR_SUCCESS {
eprintln!(
"[ax_ui] ax_try_copy_value: AXUIElementGetPid failed (err={}) for attr '{attr_name}'",
pid_result
);
return None;
}
let attr = ax_attr(attr_name);
let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef());
if raw.is_null() {
eprintln!("[ax_ui] ax_try_copy_value: AXUIElementCopyAttributeValue returned null for attr '{attr_name}'");
return None;
}
Some(raw)
}

/// Check Accessibility permission and return an error if not granted.
Expand Down Expand Up @@ -117,29 +144,21 @@ mod macos_impl {
}

unsafe fn ax_get_string(element: AXUIElementRef, attr_name: &str) -> Option<String> {
if !ax_element_is_valid(element) {
return None;
}
let attr = ax_attr(attr_name);
let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef());
let raw = ax_try_copy_value(element, attr_name)?;
cf_to_string(raw)
}

unsafe fn ax_get_children(element: AXUIElementRef) -> Vec<AXUIElementRef> {
if !ax_element_is_valid(element) {
return vec![];
}
let attr = ax_attr("AXChildren");
let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef());
if raw.is_null() {
return vec![];
}
let raw = match ax_try_copy_value(element, "AXChildren") {
Some(r) => r,
None => return vec![],
};
let arr = CFArray::<*const c_void>::wrap_under_get_rule(raw as CFArrayRef);
let mut children = Vec::new();
for i in 0..arr.len() {
if let Some(ptr_val) = arr.get(i) {
let val = *ptr_val;
if !val.is_null() {
if !val.is_null() && (val as usize) >= MIN_VALID_PTR {
CFRetain(val as CFTypeRef);
children.push(val);
}
Expand All @@ -149,20 +168,16 @@ mod macos_impl {
}

unsafe fn ax_get_attr_array(element: AXUIElementRef, attr_name: &str) -> Vec<AXUIElementRef> {
if !ax_element_is_valid(element) {
return vec![];
}
let attr = ax_attr(attr_name);
let raw = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef());
if raw.is_null() {
return vec![];
}
let raw = match ax_try_copy_value(element, attr_name) {
Some(r) => r,
None => return vec![],
};
let arr = CFArray::<*const c_void>::wrap_under_get_rule(raw as CFArrayRef);
let mut items = Vec::new();
for i in 0..arr.len() {
if let Some(ptr_val) = arr.get(i) {
let val = *ptr_val;
if !val.is_null() {
if !val.is_null() && (val as usize) >= MIN_VALID_PTR {
CFRetain(val as CFTypeRef);
items.push(val);
}
Expand All @@ -187,11 +202,17 @@ mod macos_impl {

const MAX_UI_DEPTH: usize = 30;

unsafe fn ax_to_ui_element(element: AXUIElementRef) -> UiElement {
ax_to_ui_element_inner(element, 0)
}
/// Convert an AXUIElementRef to a UiElement.
/// Returns None if the element is invalid or an error occurs.
unsafe fn ax_to_ui_element_safe(element: AXUIElementRef, depth: usize) -> Option<UiElement> {
if element.is_null() || (element as usize) < MIN_VALID_PTR {
eprintln!(
"[ax_ui] ax_to_ui_element_safe: invalid element {:#x} at depth {depth}",
element as usize
);
return None;
}

unsafe fn ax_to_ui_element_inner(element: AXUIElementRef, depth: usize) -> UiElement {
let role = ax_get_string(element, "AXRole").unwrap_or_else(|| "unknown".to_string());
let title = ax_get_string(element, "AXTitle");
let value = ax_get_string(element, "AXValue");
Expand All @@ -201,31 +222,38 @@ mod macos_impl {
vec![]
} else {
let children_elements = ax_get_children(element);
let result: Vec<UiElement> = children_elements
.iter()
.map(|&child| ax_to_ui_element_inner(child, depth + 1))
.collect();
let mut result = Vec::new();
for &child in &children_elements {
if let Some(ui_child) = ax_to_ui_element_safe(child, depth + 1) {
result.push(ui_child);
}
}
ax_release_all(&children_elements);
result
};

UiElement {
Some(UiElement {
role,
title,
value,
description,
bounds: None,
children,
}
})
}

unsafe fn ax_find_in_tree(
element: AXUIElementRef,
role: Option<&str>,
title: Option<&str>,
) -> Vec<UiElement> {
let ui = ax_to_ui_element(element);
find_ui_matches(&ui, role, title)
match ax_to_ui_element_safe(element, 0) {
Some(ui) => find_ui_matches(&ui, role, title),
None => {
eprintln!("[ax_ui] ax_find_in_tree: failed to convert root element");
vec![]
}
}
}

fn find_ui_matches(
Expand Down Expand Up @@ -311,26 +339,47 @@ mod macos_impl {
let pid = get_pid_for_window(window_id)
.ok_or_else(|| AppError::WindowNotFound(format!("Window {window_id} not found")))?;

eprintln!("[ax_ui] inspect_window: window_id={window_id}, pid={pid}");

unsafe {
let app = AXUIElementCreateApplication(pid);
if app.is_null() {
eprintln!("[ax_ui] inspect_window: AXUIElementCreateApplication returned null for pid {pid}");
return Err(AppError::Accessibility(
"Failed to create AXUIElement for application".to_string(),
));
}

let windows = ax_get_attr_array(app, "AXWindows");
if windows.is_empty() {
eprintln!("[ax_ui] inspect_window: no AXWindows for pid {pid}");
ax_release_one(app);
return Err(AppError::WindowNotFound(format!(
"No AXWindows for PID {pid}"
)));
}

let ui = ax_to_ui_element(windows[0]);
ax_release_all(&windows);
ax_release_one(app);
Ok(ui)
let first_window = windows[0];
eprintln!(
"[ax_ui] inspect_window: first window ptr = {:#x}",
first_window as usize
);

match ax_to_ui_element_safe(first_window, 0) {
Some(ui) => {
ax_release_all(&windows);
ax_release_one(app);
Ok(ui)
}
None => {
eprintln!("[ax_ui] inspect_window: failed to convert window element");
ax_release_all(&windows);
ax_release_one(app);
Err(AppError::Accessibility(
"Failed to read UI tree from window".to_string(),
))
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,4 @@ A: 等待几秒让 Claude 启动,终端会自动连接 PTY 输出。

---

**版本**: v${VERSION} | **构建时间**: __BUILD_DATE__
**版本**: v${VERSION} | **构建时间**: 2026-05-20 22:19
4 changes: 2 additions & 2 deletions sandbox-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>System Test Sandbox</title>
<style>
/* Prevent flash of unstyled content */
/* Prevent flash of unstyled content — match dark default */
html, body, #root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #1a1b26;
background: #24283b;
}
</style>
</head>
Expand Down
1 change: 0 additions & 1 deletion sandbox-web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,3 @@ export async function listWindows(): Promise<[number, string][]> {
const res = await fetch(`${BASE()}/windows`);
return res.json();
}

Loading
Loading