chrome_cli/chrome/
discovery.rs1use 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#[derive(Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13#[allow(dead_code)]
14pub struct BrowserVersion {
15 #[serde(rename = "Browser")]
17 pub browser: String,
18
19 #[serde(rename = "Protocol-Version")]
21 pub protocol_version: String,
22
23 #[serde(rename = "webSocketDebuggerUrl")]
25 pub ws_debugger_url: String,
26}
27
28#[derive(Debug, Clone, Deserialize)]
30#[serde(rename_all = "camelCase")]
31#[allow(dead_code)]
32pub struct TargetInfo {
33 pub id: String,
35
36 #[serde(rename = "type")]
38 pub target_type: String,
39
40 pub title: String,
42
43 pub url: String,
45
46 #[serde(rename = "webSocketDebuggerUrl")]
48 pub ws_debugger_url: Option<String>,
49}
50
51pub 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#[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
74pub 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
89pub 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
102pub 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
120fn 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
137pub async fn discover_chrome(host: &str, port: u16) -> Result<(String, u16), ChromeError> {
146 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 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
160fn 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; let headers = &buf[..header_end];
167 match parse_content_length(headers) {
168 Some(cl) => buf.len() >= body_start + cl,
169 None => true, }
171}
172
173fn find_header_end(buf: &[u8]) -> Option<usize> {
175 buf.windows(4).position(|w| w == b"\r\n\r\n")
176}
177
178fn 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
191fn 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 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 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
225async 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 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, 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 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 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}