use allwright_surface_desktop::{
ConnectOptions, DesktopAppSessionHandle, DesktopCommand, DesktopCommandResult, DesktopPlatform,
DesktopSessionHandle, LaunchOptions,
};
use base64::Engine;
use serde::Deserialize;
use serde_json::{Value, json};
use std::error::Error;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::process::{Command, Stdio};
const INDEX_HTML: &str = include_str!("inspector.html");
pub fn run(
surface: &str,
target: &str,
url: Option<&str>,
listen_addr: SocketAddr,
open_browser: bool,
) -> Result<(), Box<dyn Error>> {
let surface = InspectorSurface::parse(surface)?;
if !listen_addr.ip().is_loopback() {
return Err("the inspector must listen on a loopback address".into());
}
let runtime = InspectorRuntime::start(surface, target, url)?;
let listener = TcpListener::bind(listen_addr)?;
let address = listener.local_addr()?;
let inspector_url = format!("http://{address}/");
println!("Inspecting `{target}` at {inspector_url}");
println!("Press Ctrl+C to stop the inspector.");
if open_browser {
open_in_browser(&inspector_url)?;
}
for connection in listener.incoming() {
match connection {
Ok(mut stream) => {
if let Err(error) = handle_request(&runtime, &mut stream) {
let _ = write_response(
&mut stream,
"500 Internal Server Error",
"application/json; charset=utf-8",
json!({"error": error.to_string()}).to_string().as_bytes(),
);
}
}
Err(error) => eprintln!("inspector connection failed: {error}"),
}
}
Ok(())
}
struct InspectorRuntime {
surface: InspectorSurface,
desktop: DesktopSessionHandle,
app: DesktopAppSessionHandle,
}
#[derive(Clone, Copy)]
struct InspectorSurface {
id: &'static str,
platform: DesktopPlatform,
backend: &'static str,
}
impl InspectorSurface {
fn parse(value: &str) -> Result<Self, Box<dyn Error>> {
match value {
"desktop-windows" => Ok(Self {
id: "desktop-windows",
platform: DesktopPlatform::Windows,
backend: "UIA3",
}),
"desktop-mac" => Ok(Self {
id: "desktop-mac",
platform: DesktopPlatform::Mac,
backend: "XCUITest",
}),
_ => Err(format!(
"inspector surface `{value}` is not supported; expected `desktop-windows` or `desktop-mac`"
)
.into()),
}
}
}
impl InspectorRuntime {
fn start(
surface: InspectorSurface,
target: &str,
url: Option<&str>,
) -> Result<Self, Box<dyn Error>> {
let connect = invoke(
surface.id,
DesktopCommand::Connect(ConnectOptions {
platform: surface.platform,
agent_endpoint: None,
timeout_ms: Some(30_000),
}),
)?;
let DesktopCommandResult::Connect(connection) = connect else {
return Err(format!("{} returned an unexpected connect response", surface.id).into());
};
let launch = invoke(
surface.id,
DesktopCommand::LaunchApp {
desktop_session: connection.desktop_session.clone(),
options: LaunchOptions {
app_id: target.to_string(),
terminate_running: false,
timeout_ms: Some(30_000),
},
},
)?;
let DesktopCommandResult::LaunchApp(application) = launch else {
return Err(format!("{} returned an unexpected launch response", surface.id).into());
};
if let Some(url) = url {
invoke(
surface.id,
DesktopCommand::NavigateApp {
desktop_session: connection.desktop_session.clone(),
app_session: application.app_session.clone(),
url: url.to_string(),
timeout_ms: Some(10_000),
},
)?;
}
Ok(Self {
surface,
desktop: connection.desktop_session,
app: application.app_session,
})
}
fn snapshot(&self) -> Result<Value, Box<dyn Error>> {
let screenshot = invoke(
self.surface.id,
DesktopCommand::Screenshot {
desktop_session: self.desktop.clone(),
app_session: self.app.clone(),
timeout_ms: Some(10_000),
full_page: false,
},
)?;
let DesktopCommandResult::Screenshot(screenshot) = screenshot else {
return Err(format!(
"{} returned an unexpected screenshot response",
self.surface.id
)
.into());
};
let tree = invoke(
self.surface.id,
DesktopCommand::AccessibilitySnapshot {
desktop_session: self.desktop.clone(),
app_session: self.app.clone(),
format: "json".to_string(),
mode: "default".to_string(),
},
)?;
let DesktopCommandResult::AccessibilitySnapshot(tree) = tree else {
return Err(format!(
"{} returned an unexpected accessibility response",
self.surface.id
)
.into());
};
let tree: Value = serde_json::from_str(&tree.snapshot)?;
Ok(json!({
"image": format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(screenshot.png_data)
),
"tree": normalize_snapshot(tree)?,
"surface": self.surface.id,
"backend": self.surface.backend,
}))
}
fn click(&self, selector: String) -> Result<Value, Box<dyn Error>> {
invoke(
self.surface.id,
DesktopCommand::ClickElement {
desktop_session: self.desktop.clone(),
app_session: self.app.clone(),
selector,
timeout_ms: Some(10_000),
},
)?;
std::thread::sleep(std::time::Duration::from_millis(200));
self.snapshot()
}
}
#[derive(Deserialize)]
struct PluginEnvelope {
ok: bool,
result: Option<DesktopCommandResult>,
error: Option<String>,
}
fn invoke(
plugin_id: &str,
command: DesktopCommand,
) -> Result<DesktopCommandResult, Box<dyn Error>> {
let request = serde_json::to_string(&command)?;
let response = allwright::plugins::invoke_plugin(plugin_id, &request)
.map_err(|error| format!("{plugin_id}: {error}"))?;
let response: PluginEnvelope = serde_json::from_str(&response)?;
if response.ok {
response
.result
.ok_or_else(|| format!("{plugin_id} returned no result").into())
} else {
Err(response
.error
.unwrap_or_else(|| format!("{plugin_id} failed without an error message"))
.into())
}
}
fn normalize_snapshot(snapshot: Value) -> Result<Value, Box<dyn Error>> {
if snapshot.get("root").is_some() {
return Ok(snapshot);
}
let document = snapshot
.get("documents")
.and_then(Value::as_array)
.and_then(|documents| documents.first())
.ok_or("accessibility snapshot does not contain a document")?;
let root = document
.get("root")
.ok_or("accessibility snapshot document does not contain a root")?;
Ok(json!({
"version": snapshot.get("version").cloned().unwrap_or(json!(1)),
"platform": document.get("platform").cloned().unwrap_or(Value::Null),
"root": normalize_xctest_node(root),
}))
}
fn normalize_xctest_node(node: &Value) -> Value {
let properties = node.get("properties").unwrap_or(&Value::Null);
let states = node.get("states").unwrap_or(&Value::Null);
let children = node
.get("children")
.and_then(Value::as_array)
.map(|children| {
children
.iter()
.map(normalize_xctest_node)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let bounds = properties
.get("frame")
.filter(|frame| {
frame.get("width").and_then(Value::as_f64).unwrap_or(0.0) > 0.0
&& frame.get("height").and_then(Value::as_f64).unwrap_or(0.0) > 0.0
})
.cloned()
.or_else(|| {
children
.iter()
.find(|child| {
child
.get("className")
.and_then(Value::as_str)
.is_some_and(|class_name| class_name.eq_ignore_ascii_case("window"))
})
.and_then(|child| child.get("bounds"))
.cloned()
})
.unwrap_or_else(empty_bounds);
json!({
"role": node.get("role").cloned().unwrap_or_else(|| json!("generic")),
"name": node.get("name").cloned().unwrap_or_else(|| json!("")),
"automationId": properties.get("identifier").cloned().unwrap_or_else(|| json!("")),
"className": properties.get("type").cloned().unwrap_or_else(|| json!("")),
"enabled": !states.get("disabled").and_then(Value::as_bool).unwrap_or(false),
"offscreen": !properties.get("rendered").and_then(Value::as_bool).unwrap_or(true),
"bounds": bounds,
"properties": properties,
"states": states,
"children": children,
})
}
fn empty_bounds() -> Value {
json!({"x": 0, "y": 0, "width": 0, "height": 0})
}
fn handle_request(
runtime: &InspectorRuntime,
stream: &mut TcpStream,
) -> Result<(), Box<dyn Error>> {
let request = read_request(stream)?;
if request
.origin
.as_deref()
.is_some_and(|origin| !is_loopback_origin(origin))
{
return Err("cross-origin inspector request was rejected".into());
}
match (request.method.as_str(), request.path.as_str()) {
("GET", "/") => write_response(
stream,
"200 OK",
"text/html; charset=utf-8",
INDEX_HTML.as_bytes(),
)?,
("GET", "/api/snapshot") => {
let body = serde_json::to_vec(&runtime.snapshot()?)?;
write_response(stream, "200 OK", "application/json", &body)?;
}
("POST", "/api/click") => {
let payload: Value = serde_json::from_slice(&request.body)?;
let selector = payload
.get("selector")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.ok_or("click requires a non-empty selector")?;
let body = serde_json::to_vec(&runtime.click(selector.to_string())?)?;
write_response(stream, "200 OK", "application/json", &body)?;
}
_ => write_response(
stream,
"404 Not Found",
"application/json",
br#"{"error":"not found"}"#,
)?,
}
Ok(())
}
struct HttpRequest {
method: String,
path: String,
origin: Option<String>,
body: Vec<u8>,
}
fn read_request(stream: &mut TcpStream) -> Result<HttpRequest, Box<dyn Error>> {
stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
let mut bytes = Vec::new();
let mut buffer = [0_u8; 4096];
let (header_end, content_length) = loop {
let read = stream.read(&mut buffer)?;
if read == 0 {
return Err("incomplete HTTP request".into());
}
bytes.extend_from_slice(&buffer[..read]);
if bytes.len() > 1024 * 1024 {
return Err("HTTP request is too large".into());
}
if let Some(end) = find_bytes(&bytes, b"\r\n\r\n") {
let headers = std::str::from_utf8(&bytes[..end])?;
let length = headers
.lines()
.find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
})
.unwrap_or(0);
break (end + 4, length);
}
};
while bytes.len() < header_end + content_length {
let read = stream.read(&mut buffer)?;
if read == 0 {
return Err("incomplete HTTP request body".into());
}
bytes.extend_from_slice(&buffer[..read]);
}
let headers = std::str::from_utf8(&bytes[..header_end - 4])?;
let request_line = headers.lines().next().ok_or("missing HTTP request line")?;
let origin = headers.lines().skip(1).find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case("origin")
.then(|| value.trim().to_string())
})
});
let mut parts = request_line.split_whitespace();
let method = parts.next().ok_or("missing HTTP method")?.to_string();
let path = parts
.next()
.ok_or("missing HTTP path")?
.split('?')
.next()
.unwrap_or("/")
.to_string();
Ok(HttpRequest {
method,
path,
origin,
body: bytes[header_end..header_end + content_length].to_vec(),
})
}
fn is_loopback_origin(origin: &str) -> bool {
["http://127.0.0.1:", "http://localhost:", "http://[::1]:"]
.iter()
.any(|prefix| origin.starts_with(prefix))
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn write_response(
stream: &mut TcpStream,
status: &str,
content_type: &str,
body: &[u8],
) -> std::io::Result<()> {
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\n\r\n",
body.len()
)?;
stream.write_all(body)
}
fn open_in_browser(url: &str) -> Result<(), Box<dyn Error>> {
let mut command = if cfg!(target_os = "windows") {
let mut command = Command::new("cmd");
command.args(["/C", "start", "", "msedge", &format!("--app={url}")]);
command
} else if cfg!(target_os = "macos") {
let mut command = Command::new("open");
command.args(["-n", "-a", "Safari", url]);
command
} else {
let mut command = Command::new("xdg-open");
command.arg(url);
command
};
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_http_header_boundary() {
assert_eq!(find_bytes(b"GET / HTTP/1.1\r\n\r\n", b"\r\n\r\n"), Some(14));
}
#[test]
fn inspector_html_has_core_workflow() {
assert!(INDEX_HTML.contains("/api/snapshot"));
assert!(INDEX_HTML.contains("/api/click"));
assert!(INDEX_HTML.contains("Element hierarchy"));
}
#[test]
fn accepts_only_loopback_browser_origins() {
assert!(is_loopback_origin("http://127.0.0.1:9227"));
assert!(is_loopback_origin("http://localhost:9227"));
assert!(!is_loopback_origin("https://example.com"));
}
#[test]
fn normalizes_xctest_document_for_the_shared_inspector() {
let snapshot = json!({
"version": 1,
"documents": [{
"platform": "macos",
"root": {
"role": "application",
"name": "TextEdit",
"states": {},
"properties": {
"type": "application",
"rendered": true
},
"children": [{
"role": "generic",
"name": "Document",
"states": {},
"properties": {
"type": "Window",
"rendered": true,
"frame": {"x": 20, "y": 40, "width": 800, "height": 600}
},
"children": [{
"role": "button",
"name": "Save",
"states": {"disabled": false},
"properties": {
"identifier": "save-button",
"type": "button",
"rendered": true,
"frame": {"x": 700, "y": 50, "width": 80, "height": 30}
},
"children": []
}]
}]
}
}]
});
let normalized = normalize_snapshot(snapshot).unwrap();
assert_eq!(normalized["root"]["bounds"]["x"], 20);
assert_eq!(
normalized["root"]["children"][0]["children"][0]["automationId"],
"save-button"
);
assert_eq!(
normalized["root"]["children"][0]["children"][0]["enabled"],
true
);
}
}