Skip to main content

chrome_cli/chrome/
discovery.rs

1use std::io::{Read, Write};
2use std::net::TcpStream;
3use std::time::Duration;
4
5use serde::Deserialize;
6
7use super::ChromeError;
8use super::platform;
9
10/// Browser version information returned by `/json/version`.
11#[derive(Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13#[allow(dead_code)]
14pub struct BrowserVersion {
15    /// The browser name and version (e.g. "Chrome/120.0.6099.71").
16    #[serde(rename = "Browser")]
17    pub browser: String,
18
19    /// The CDP protocol version (e.g. "1.3").
20    #[serde(rename = "Protocol-Version")]
21    pub protocol_version: String,
22
23    /// The browser-level WebSocket debugger URL.
24    #[serde(rename = "webSocketDebuggerUrl")]
25    pub ws_debugger_url: String,
26}
27
28/// Information about a single debuggable target (tab, service worker, etc.).
29#[derive(Debug, Clone, Deserialize)]
30#[serde(rename_all = "camelCase")]
31#[allow(dead_code)]
32pub struct TargetInfo {
33    /// Unique target identifier.
34    pub id: String,
35
36    /// Target type (e.g. "page", "`background_page`").
37    #[serde(rename = "type")]
38    pub target_type: String,
39
40    /// Page title.
41    pub title: String,
42
43    /// Current URL.
44    pub url: String,
45
46    /// WebSocket URL to debug this specific target.
47    #[serde(rename = "webSocketDebuggerUrl")]
48    pub ws_debugger_url: Option<String>,
49}
50
51/// Query Chrome's `/json/version` endpoint.
52///
53/// # Errors
54///
55/// Returns `ChromeError::HttpError` on connection failure or `ChromeError::ParseError`
56/// if the response cannot be deserialized.
57pub async fn query_version(host: &str, port: u16) -> Result<BrowserVersion, ChromeError> {
58    let body = http_get(host, port, "/json/version").await?;
59    serde_json::from_str(&body).map_err(|e| ChromeError::ParseError(e.to_string()))
60}
61
62/// Query Chrome's `/json/list` endpoint for debuggable targets.
63///
64/// # Errors
65///
66/// Returns `ChromeError::HttpError` on connection failure or `ChromeError::ParseError`
67/// if the response cannot be deserialized.
68#[allow(dead_code)]
69pub async fn query_targets(host: &str, port: u16) -> Result<Vec<TargetInfo>, ChromeError> {
70    let body = http_get(host, port, "/json/list").await?;
71    serde_json::from_str(&body).map_err(|e| ChromeError::ParseError(e.to_string()))
72}
73
74/// Activate a target via Chrome's `/json/activate/{id}` endpoint.
75///
76/// This uses the same HTTP server as `/json/list`, avoiding the cross-protocol
77/// synchronization gap that occurs when using CDP `Target.activateTarget`
78/// followed by an HTTP `/json/list` query.
79///
80/// # Errors
81///
82/// Returns `ChromeError::HttpError` on connection failure.
83pub async fn activate_target(host: &str, port: u16, target_id: &str) -> Result<(), ChromeError> {
84    let path = format!("/json/activate/{target_id}");
85    let _body = http_get(host, port, &path).await?;
86    Ok(())
87}
88
89/// Read the `DevToolsActivePort` file from the default user data directory.
90///
91/// Returns `(port, ws_path)` on success.
92///
93/// # Errors
94///
95/// Returns `ChromeError::NoActivePort` if the file is missing or unreadable,
96/// or `ChromeError::ParseError` if the contents are malformed.
97pub fn read_devtools_active_port() -> Result<(u16, String), ChromeError> {
98    let data_dir = platform::default_user_data_dir().ok_or(ChromeError::NoActivePort)?;
99    read_devtools_active_port_from(&data_dir)
100}
101
102/// Read the `DevToolsActivePort` file from a specific directory.
103///
104/// This is the parameterized version of [`read_devtools_active_port`] that accepts
105/// an explicit data directory, enabling unit testing without relying on
106/// platform-specific defaults.
107///
108/// # Errors
109///
110/// Returns `ChromeError::NoActivePort` if the file is missing or unreadable,
111/// or `ChromeError::ParseError` if the contents are malformed.
112pub fn read_devtools_active_port_from(
113    data_dir: &std::path::Path,
114) -> Result<(u16, String), ChromeError> {
115    let path = data_dir.join("DevToolsActivePort");
116    let contents = std::fs::read_to_string(&path).map_err(|_| ChromeError::NoActivePort)?;
117    parse_devtools_active_port(&contents)
118}
119
120/// Parse the contents of a `DevToolsActivePort` file.
121///
122/// The file has two lines: a port number and a WebSocket path.
123fn parse_devtools_active_port(contents: &str) -> Result<(u16, String), ChromeError> {
124    let mut lines = contents.lines();
125    let port_str = lines.next().ok_or(ChromeError::NoActivePort)?;
126    let port: u16 = port_str.trim().parse().map_err(|_| {
127        ChromeError::ParseError(format!("invalid port in DevToolsActivePort: {port_str}"))
128    })?;
129    let ws_path = lines
130        .next()
131        .ok_or(ChromeError::NoActivePort)?
132        .trim()
133        .to_string();
134    Ok((port, ws_path))
135}
136
137/// Attempt to discover a running Chrome instance.
138///
139/// Tries `DevToolsActivePort` file first, then falls back to the given host/port.
140/// Returns the WebSocket URL and port on success.
141///
142/// # Errors
143///
144/// Returns `ChromeError::NotRunning` if no Chrome instance can be discovered.
145pub async fn discover_chrome(host: &str, port: u16) -> Result<(String, u16), ChromeError> {
146    // Try DevToolsActivePort file first
147    if let Ok((file_port, _ws_path)) = read_devtools_active_port() {
148        if let Ok(version) = query_version("127.0.0.1", file_port).await {
149            return Ok((version.ws_debugger_url, file_port));
150        }
151    }
152
153    // Fall back to the explicitly given host/port
154    query_version(host, port)
155        .await
156        .map(|version| (version.ws_debugger_url, port))
157        .map_err(|e| ChromeError::NotRunning(format!("discovery failed on {host}:{port}: {e}")))
158}
159
160/// Check whether `buf` contains a complete HTTP response (headers + full body per Content-Length).
161fn is_http_response_complete(buf: &[u8]) -> bool {
162    let Some(header_end) = find_header_end(buf) else {
163        return false;
164    };
165    let body_start = header_end + 4; // skip past \r\n\r\n
166    let headers = &buf[..header_end];
167    match parse_content_length(headers) {
168        Some(cl) => buf.len() >= body_start + cl,
169        None => true, // no Content-Length; headers are complete, assume body is too
170    }
171}
172
173/// Find the byte offset of `\r\n\r\n` in `buf`, returning the position of the first `\r`.
174fn find_header_end(buf: &[u8]) -> Option<usize> {
175    buf.windows(4).position(|w| w == b"\r\n\r\n")
176}
177
178/// Parse `Content-Length` from raw header bytes (case-insensitive).
179fn parse_content_length(headers: &[u8]) -> Option<usize> {
180    let header_str = std::str::from_utf8(headers).ok()?;
181    for line in header_str.lines() {
182        if let Some((key, value)) = line.split_once(':') {
183            if key.trim().eq_ignore_ascii_case("content-length") {
184                return value.trim().parse().ok();
185            }
186        }
187    }
188    None
189}
190
191/// Parse a raw HTTP response buffer into the body string.
192///
193/// Validates the status line is 200 OK and extracts the body after headers.
194fn parse_http_response(buf: &[u8]) -> Result<String, ChromeError> {
195    let header_end = find_header_end(buf)
196        .ok_or_else(|| ChromeError::HttpError("malformed HTTP response".into()))?;
197    let body_start = header_end + 4;
198
199    let headers = std::str::from_utf8(&buf[..header_end])
200        .map_err(|e| ChromeError::HttpError(format!("invalid UTF-8 in headers: {e}")))?;
201
202    // Check for HTTP 200 status
203    let status_line = headers
204        .lines()
205        .next()
206        .ok_or_else(|| ChromeError::HttpError("empty response".into()))?;
207    if !status_line.contains(" 200 ") {
208        return Err(ChromeError::HttpError(format!(
209            "unexpected HTTP status: {status_line}"
210        )));
211    }
212
213    // Extract body: use Content-Length if available, otherwise take everything after headers
214    let body_bytes = if let Some(cl) = parse_content_length(&buf[..header_end]) {
215        let end = (body_start + cl).min(buf.len());
216        &buf[body_start..end]
217    } else {
218        &buf[body_start..]
219    };
220
221    String::from_utf8(body_bytes.to_vec())
222        .map_err(|e| ChromeError::HttpError(format!("invalid UTF-8 in body: {e}")))
223}
224
225/// Perform a simple HTTP GET request using blocking I/O in a `spawn_blocking` context.
226async fn http_get(host: &str, port: u16, path: &str) -> Result<String, ChromeError> {
227    let addr = format!("{host}:{port}");
228    let request = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
229
230    let (addr_clone, request_clone) = (addr.clone(), request);
231    tokio::task::spawn_blocking(move || {
232        let mut stream = TcpStream::connect_timeout(
233            &addr_clone
234                .parse()
235                .map_err(|e| ChromeError::HttpError(format!("invalid address: {e}")))?,
236            Duration::from_secs(2),
237        )
238        .map_err(|e| ChromeError::HttpError(format!("connection failed to {addr_clone}: {e}")))?;
239
240        stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
241
242        stream
243            .write_all(request_clone.as_bytes())
244            .map_err(|e| ChromeError::HttpError(format!("write failed: {e}")))?;
245
246        // Read response incrementally, stopping once we have Content-Length bytes
247        // of body. This avoids blocking on EOF when Chrome keeps the connection open.
248        let mut buf = Vec::with_capacity(4096);
249        let mut tmp = [0u8; 4096];
250        loop {
251            match stream.read(&mut tmp) {
252                Ok(0) => break, // EOF
253                Ok(n) => {
254                    buf.extend_from_slice(&tmp[..n]);
255                    if is_http_response_complete(&buf) {
256                        break;
257                    }
258                }
259                Err(e)
260                    if e.kind() == std::io::ErrorKind::WouldBlock
261                        || e.kind() == std::io::ErrorKind::TimedOut =>
262                {
263                    // Timeout/EAGAIN: if we already have a complete response, use it
264                    if is_http_response_complete(&buf) {
265                        break;
266                    }
267                    return Err(ChromeError::HttpError(format!("read timed out: {e}")));
268                }
269                Err(e) => {
270                    return Err(ChromeError::HttpError(format!("read failed: {e}")));
271                }
272            }
273        }
274
275        parse_http_response(&buf)
276    })
277    .await
278    .map_err(|e| ChromeError::HttpError(format!("task join failed: {e}")))?
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn parse_browser_version() {
287        let json = r#"{
288            "Browser": "Chrome/120.0.6099.71",
289            "Protocol-Version": "1.3",
290            "User-Agent": "Mozilla/5.0",
291            "V8-Version": "12.0.267.8",
292            "WebKit-Version": "537.36",
293            "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/abc-123"
294        }"#;
295        let v: BrowserVersion = serde_json::from_str(json).unwrap();
296        assert_eq!(v.browser, "Chrome/120.0.6099.71");
297        assert_eq!(v.protocol_version, "1.3");
298        assert!(v.ws_debugger_url.contains("ws://"));
299    }
300
301    #[test]
302    fn parse_target_info() {
303        let json = r#"[{
304            "description": "",
305            "devtoolsFrontendUrl": "/devtools/inspector.html",
306            "id": "ABCDEF",
307            "title": "New Tab",
308            "type": "page",
309            "url": "chrome://newtab/",
310            "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/ABCDEF"
311        }]"#;
312        let targets: Vec<TargetInfo> = serde_json::from_str(json).unwrap();
313        assert_eq!(targets.len(), 1);
314        assert_eq!(targets[0].id, "ABCDEF");
315        assert_eq!(targets[0].target_type, "page");
316        assert_eq!(targets[0].title, "New Tab");
317        assert!(targets[0].ws_debugger_url.is_some());
318    }
319
320    #[test]
321    fn parse_devtools_active_port_valid() {
322        let contents = "9222\n/devtools/browser/abc-123\n";
323        let (port, path) = parse_devtools_active_port(contents).unwrap();
324        assert_eq!(port, 9222);
325        assert_eq!(path, "/devtools/browser/abc-123");
326    }
327
328    #[test]
329    fn parse_devtools_active_port_empty() {
330        let result = parse_devtools_active_port("");
331        assert!(result.is_err());
332    }
333
334    #[test]
335    fn parse_devtools_active_port_invalid_port() {
336        let result = parse_devtools_active_port("notaport\n/ws/path\n");
337        assert!(result.is_err());
338    }
339
340    #[test]
341    fn read_devtools_active_port_from_dir() {
342        let dir = std::env::temp_dir().join("chrome-cli-test-devtools-port");
343        std::fs::create_dir_all(&dir).unwrap();
344        let file = dir.join("DevToolsActivePort");
345        std::fs::write(&file, "9333\n/devtools/browser/xyz-789\n").unwrap();
346
347        let (port, path) = read_devtools_active_port_from(&dir).unwrap();
348        assert_eq!(port, 9333);
349        assert_eq!(path, "/devtools/browser/xyz-789");
350
351        // Clean up
352        let _ = std::fs::remove_dir_all(&dir);
353    }
354
355    #[test]
356    fn read_devtools_active_port_from_missing_dir() {
357        let dir = std::path::Path::new("/nonexistent/chrome-cli-test");
358        let result = read_devtools_active_port_from(dir);
359        assert!(result.is_err());
360    }
361
362    #[test]
363    fn parse_http_response_with_content_length() {
364        let raw = b"HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, world!";
365        let body = parse_http_response(raw).unwrap();
366        assert_eq!(body, "Hello, world!");
367    }
368
369    #[test]
370    fn parse_http_response_without_content_length() {
371        let raw = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n{\"ok\":true}";
372        let body = parse_http_response(raw).unwrap();
373        assert_eq!(body, "{\"ok\":true}");
374    }
375
376    #[test]
377    fn parse_http_response_content_length_zero() {
378        let raw = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n";
379        let body = parse_http_response(raw).unwrap();
380        assert_eq!(body, "");
381    }
382
383    #[test]
384    fn parse_http_response_malformed_no_separator() {
385        let raw = b"HTTP/1.1 200 OK\nno double crlf here";
386        let result = parse_http_response(raw);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn parse_http_response_non_200_status() {
392        let raw = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
393        let result = parse_http_response(raw);
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn is_http_response_complete_with_content_length() {
399        let partial = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHe";
400        assert!(!is_http_response_complete(partial));
401
402        let complete = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nHello";
403        assert!(is_http_response_complete(complete));
404    }
405
406    #[test]
407    fn is_http_response_complete_no_headers_yet() {
408        assert!(!is_http_response_complete(b"HTTP/1.1 200 OK\r\n"));
409    }
410
411    #[test]
412    fn is_http_response_complete_without_content_length() {
413        let response = b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nbody";
414        assert!(is_http_response_complete(response));
415    }
416}