use crate::accessibility::{self, AXUIElementRef};
use crate::element::AXElement;
use crate::error::{AXError, AXResult};
use core_foundation::base::{CFTypeRef, TCFType};
use core_foundation::string::CFString;
use serde::Deserialize;
use serde_json::{json, Value};
use std::io::{Read, Write};
use std::net::TcpStream;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use sysinfo::System;
use tracing::{debug, info, warn};
use tungstenite::stream::MaybeTlsStream;
use tungstenite::{connect, Message, WebSocket};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppType {
Native,
Electron,
WebViewHybrid,
Catalyst,
}
impl AppType {
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Self::Native => "Native",
Self::Electron => "Electron",
Self::WebViewHybrid => "WebView Hybrid",
Self::Catalyst => "Catalyst",
}
}
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct CDPConnection {
socket: WebSocket<MaybeTlsStream<TcpStream>>,
target_id: String,
message_id: Arc<AtomicU32>,
}
impl CDPConnection {
pub fn connect(pid: i32) -> Option<Self> {
debug!(pid, "Attempting CDP connection");
let port = Self::find_debug_port(pid)?;
debug!(pid, port, "Found CDP debug port");
let ws_url = format!("ws://127.0.0.1:{port}/devtools/browser");
let (socket, _) = connect(&ws_url).ok()?;
info!(pid, port, "CDP connection established");
Some(Self {
socket,
target_id: String::new(),
message_id: Arc::new(AtomicU32::new(1)),
})
}
fn find_debug_port(pid: i32) -> Option<u16> {
let mut system = System::new_all();
system.refresh_all();
let process = system.process(sysinfo::Pid::from_u32(pid as u32))?;
for arg in process.cmd() {
let arg_str = arg.to_string_lossy();
if let Some(port_str) = arg_str.strip_prefix("--remote-debugging-port=") {
if let Ok(port) = port_str.parse::<u16>() {
return Some(port);
}
}
}
[9222, 9223, 9224, 9225]
.into_iter()
.find(|&port| Self::test_cdp_port(port))
}
fn test_cdp_port(port: u16) -> bool {
let addr = format!("127.0.0.1:{port}");
if let Ok(mut stream) = TcpStream::connect(&addr) {
let request = format!("GET /json/version HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n");
if stream.write_all(request.as_bytes()).is_ok() {
let mut buf = [0u8; 1024];
if let Ok(n) = stream.read(&mut buf) {
let response = String::from_utf8_lossy(&buf[..n]);
return response.contains("Browser")
|| response.contains("webSocketDebuggerUrl");
}
}
}
false
}
pub fn execute(&mut self, method: &str, params: Value) -> AXResult<Value> {
let id = self.message_id.fetch_add(1, Ordering::SeqCst);
let request = json!({
"id": id,
"method": method,
"params": params,
});
debug!(method, ?params, "Sending CDP request");
self.socket
.send(Message::Text(request.to_string()))
.map_err(|e| AXError::SystemError(format!("CDP send failed: {e}")))?;
loop {
let msg = self
.socket
.read()
.map_err(|e| AXError::SystemError(format!("CDP read failed: {e}")))?;
if let Message::Text(text) = msg {
let response: CDPResponse = serde_json::from_str(&text)
.map_err(|e| AXError::SystemError(format!("CDP parse failed: {e}")))?;
if response.id == Some(id) {
if let Some(error) = response.error {
return Err(AXError::ActionFailed(format!(
"CDP error: {}",
error.message
)));
}
return Ok(response.result.unwrap_or(Value::Null));
}
}
}
}
pub fn find_element(&mut self, selector: &str) -> Option<CDPElement> {
let doc = self
.execute("DOM.getDocument", json!({ "depth": -1 }))
.ok()?;
let root_node_id = doc["root"]["nodeId"].as_i64()?;
let result = self
.execute(
"DOM.querySelector",
json!({
"nodeId": root_node_id,
"selector": selector,
}),
)
.ok()?;
let node_id = result["nodeId"].as_i64()?;
if node_id == 0 {
return None; }
Some(CDPElement {
node_id,
connection: None, })
}
}
#[derive(Debug, Deserialize)]
struct CDPResponse {
id: Option<u32>,
result: Option<Value>,
error: Option<CDPError>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct CDPError {
code: i32,
message: String,
}
#[derive(Debug)]
pub struct CDPElement {
pub node_id: i64,
connection: Option<Arc<std::sync::Mutex<CDPConnection>>>,
}
impl CDPElement {
pub fn click(&mut self) -> AXResult<()> {
let conn = self
.connection
.as_ref()
.ok_or_else(|| AXError::SystemError("No CDP connection".into()))?;
let mut conn = conn
.lock()
.map_err(|_| AXError::SystemError("CDP lock poisoned".into()))?;
let box_model = conn.execute(
"DOM.getBoxModel",
json!({
"nodeId": self.node_id,
}),
)?;
let content = &box_model["model"]["content"];
let coords = content
.as_array()
.ok_or_else(|| AXError::SystemError("Invalid box model".into()))?;
let x0 = coords[0].as_f64().unwrap_or(0.0);
let x1 = coords[4].as_f64().unwrap_or(0.0);
let y0 = coords[1].as_f64().unwrap_or(0.0);
let y1 = coords[5].as_f64().unwrap_or(0.0);
let x = (x0 + x1) * 0.5;
let y = (y0 + y1) * 0.5;
conn.execute(
"Input.dispatchMouseEvent",
json!({
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}),
)?;
conn.execute(
"Input.dispatchMouseEvent",
json!({
"type": "mouseReleased",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}),
)?;
Ok(())
}
pub fn text(&mut self) -> AXResult<String> {
let conn = self
.connection
.as_ref()
.ok_or_else(|| AXError::SystemError("No CDP connection".into()))?;
let mut conn = conn
.lock()
.map_err(|_| AXError::SystemError("CDP lock poisoned".into()))?;
let result = conn.execute(
"DOM.getOuterHTML",
json!({
"nodeId": self.node_id,
}),
)?;
Ok(result["outerHTML"].as_str().unwrap_or("").to_string())
}
}
#[allow(dead_code)]
pub struct WebViewBridge {
webview_element: AXUIElementRef,
}
impl WebViewBridge {
#[must_use]
pub fn from_element(element: AXUIElementRef) -> Option<Self> {
let role = accessibility::get_attribute(element, accessibility::attributes::AX_ROLE)
.ok()
.and_then(|cf| unsafe { cfstring_to_string(cf) })?;
if role == accessibility::roles::AX_WEB_AREA || role.contains("Web") {
Some(Self {
webview_element: element,
})
} else {
None
}
}
pub fn execute_js(&self, _script: &str) -> AXResult<String> {
warn!("WebView JS execution not fully implemented - requires app cooperation");
Err(AXError::ActionFailed(
"WebView JS execution requires app-specific implementation".into(),
))
}
pub fn find_web_element(&self, _selector: &str) -> Option<AXElement> {
warn!("WebView element search not fully implemented");
None
}
}
#[allow(dead_code)]
pub struct TestRouter {
app_type: AppType,
cdp: Option<Arc<std::sync::Mutex<CDPConnection>>>,
webview: Option<WebViewBridge>,
native_element: AXUIElementRef,
pid: i32,
bundle_id: String,
}
impl TestRouter {
pub fn new(pid: i32, bundle_id: &str, element: AXUIElementRef) -> Self {
let app_type = detect_app_type(bundle_id, pid);
info!(pid, bundle_id, ?app_type, "Router initialized");
let cdp = if app_type == AppType::Electron {
CDPConnection::connect(pid).map(|c| Arc::new(std::sync::Mutex::new(c)))
} else {
None
};
let webview = if app_type == AppType::WebViewHybrid {
WebViewBridge::from_element(element)
} else {
None
};
Self {
app_type,
cdp,
webview,
native_element: element,
pid,
bundle_id: bundle_id.to_string(),
}
}
#[must_use]
pub fn app_type(&self) -> AppType {
self.app_type
}
pub fn find_element(&mut self, query: &str) -> AXResult<AXElement> {
debug!(query, ?self.app_type, "Finding element");
match self.app_type {
AppType::Electron if is_css_selector(query) => {
if let Some(ref cdp) = self.cdp {
let mut conn = cdp
.lock()
.map_err(|_| AXError::SystemError("CDP lock poisoned".into()))?;
if let Some(mut cdp_elem) = conn.find_element(query) {
cdp_elem.connection = Some(Arc::clone(cdp));
return Ok(AXElement::new(self.native_element));
}
}
Err(AXError::ElementNotFound(query.to_string()))
}
AppType::WebViewHybrid if is_css_selector(query) => {
if let Some(ref bridge) = self.webview {
if let Some(elem) = bridge.find_web_element(query) {
return Ok(elem);
}
}
Err(AXError::ElementNotFound(query.to_string()))
}
_ => {
self.find_native_element(query)
}
}
}
fn find_native_element(&self, query: &str) -> AXResult<AXElement> {
let _children_cf = accessibility::get_attribute(
self.native_element,
accessibility::attributes::AX_CHILDREN,
)?;
warn!("Native element search not fully implemented");
Err(AXError::ElementNotFound(query.to_string()))
}
}
pub fn detect_app_type(bundle_id: &str, pid: i32) -> AppType {
const ELECTRON_APPS: &[&str] = &[
"com.github.GitHubClient",
"com.microsoft.VSCode",
"com.tinyspeck.slackmacgap",
"com.hnc.Discord",
"com.squirrel.slack.Slack",
"org.whispersystems.signal-desktop",
"com.electron.",
];
for electron_id in ELECTRON_APPS {
if bundle_id.contains(electron_id) {
debug!(bundle_id, "Detected Electron app by bundle ID");
return AppType::Electron;
}
}
if has_chromium_helper(pid) {
debug!(pid, "Detected Electron app by Chromium Helper");
return AppType::Electron;
}
if bundle_id.contains("maccatalyst") || is_catalyst_app(pid) {
debug!(bundle_id, "Detected Catalyst app");
return AppType::Catalyst;
}
if has_webview(pid) {
debug!(pid, "Detected WebView hybrid app");
return AppType::WebViewHybrid;
}
debug!(bundle_id, "Detected native macOS app");
AppType::Native
}
fn has_chromium_helper(pid: i32) -> bool {
let mut system = System::new_all();
system.refresh_all();
for process in system.processes().values() {
if let Some(parent_pid) = process.parent() {
if parent_pid.as_u32() == pid as u32 {
let name = process.name().to_string_lossy();
if name.contains("Chromium Helper") || name.contains("Electron Helper") {
return true;
}
}
}
}
false
}
fn is_catalyst_app(pid: i32) -> bool {
let mut system = System::new_all();
system.refresh_all();
if let Some(process) = system.process(sysinfo::Pid::from_u32(pid as u32)) {
for arg in process.cmd() {
let arg_str = arg.to_string_lossy();
if arg_str.contains("UIKitSystem") || arg_str.contains("maccatalyst") {
return true;
}
}
}
false
}
fn has_webview(_pid: i32) -> bool {
false
}
fn is_css_selector(query: &str) -> bool {
query.starts_with('#')
|| query.starts_with('.')
|| query.starts_with('[')
|| query.contains('>')
|| query.contains('+')
|| query.contains('~')
}
unsafe fn cfstring_to_string(cf: CFTypeRef) -> Option<String> {
if cf.is_null() {
return None;
}
let cfstring = CFString::wrap_under_get_rule(cf.cast());
Some(cfstring.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_type_names() {
assert_eq!(AppType::Native.name(), "Native");
assert_eq!(AppType::Electron.name(), "Electron");
assert_eq!(AppType::WebViewHybrid.name(), "WebView Hybrid");
assert_eq!(AppType::Catalyst.name(), "Catalyst");
}
#[test]
fn test_css_selector_detection() {
assert!(is_css_selector("#myId"));
assert!(is_css_selector(".myClass"));
assert!(is_css_selector("[data-test='value']"));
assert!(is_css_selector("div > span"));
assert!(is_css_selector("button + input"));
assert!(is_css_selector("p ~ a"));
assert!(!is_css_selector("myButton"));
assert!(!is_css_selector("Submit Button"));
}
#[test]
fn test_electron_detection_by_bundle_id() {
assert_eq!(
detect_app_type("com.microsoft.VSCode", 0),
AppType::Electron
);
assert_eq!(
detect_app_type("com.tinyspeck.slackmacgap", 0),
AppType::Electron
);
assert_eq!(
detect_app_type("com.github.GitHubClient", 0),
AppType::Electron
);
}
#[test]
fn test_native_detection() {
assert_eq!(detect_app_type("com.apple.Safari", 0), AppType::Native);
assert_eq!(detect_app_type("com.apple.Finder", 0), AppType::Native);
assert_eq!(detect_app_type("com.mycompany.MyApp", 0), AppType::Native);
}
#[test]
fn test_catalyst_detection() {
assert_eq!(
detect_app_type("com.apple.maccatalyst.app", 0),
AppType::Catalyst
);
}
#[test]
fn test_cdp_port_range() {
let port = CDPConnection::find_debug_port(999999);
assert!(port.is_none()); }
#[test]
fn test_app_type_equality() {
assert_eq!(AppType::Native, AppType::Native);
assert_ne!(AppType::Native, AppType::Electron);
assert_ne!(AppType::Electron, AppType::WebViewHybrid);
assert_ne!(AppType::WebViewHybrid, AppType::Catalyst);
}
#[test]
fn test_chromium_helper_detection() {
assert!(!has_chromium_helper(999999));
}
#[test]
fn test_catalyst_app_detection() {
assert!(!is_catalyst_app(999999));
}
#[test]
fn test_webview_detection() {
assert!(!has_webview(999999));
}
#[test]
#[ignore] fn test_cdp_connection_integration() {
let conn = CDPConnection::connect(999999); assert!(conn.is_none()); }
#[test]
fn test_cdp_test_port_with_invalid_port() {
assert!(!CDPConnection::test_cdp_port(65534));
}
#[test]
fn test_router_native_app() {
let element = std::ptr::null(); let router = TestRouter::new(1, "com.apple.Safari", element);
assert_eq!(router.app_type(), AppType::Native);
assert!(router.cdp.is_none());
assert!(router.webview.is_none());
}
#[test]
fn test_router_electron_app() {
let element = std::ptr::null(); let router = TestRouter::new(1, "com.microsoft.VSCode", element);
assert_eq!(router.app_type(), AppType::Electron);
}
#[test]
fn test_query_routing() {
let element = std::ptr::null();
let mut router = TestRouter::new(1, "com.apple.Safari", element);
let result = router.find_element("#myButton");
assert!(result.is_err());
let result = router.find_element("Submit Button");
assert!(result.is_err()); }
#[test]
fn test_cdp_element_without_connection() {
let mut elem = CDPElement {
node_id: 123,
connection: None,
};
assert!(elem.click().is_err());
assert!(elem.text().is_err());
}
#[test]
fn test_webview_bridge_invalid_element() {
let element = std::ptr::null();
let bridge = WebViewBridge::from_element(element);
assert!(bridge.is_none());
}
}