1use std::io::{BufRead, BufReader, Read};
4use std::net::TcpStream;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, Stdio};
7use std::time::{Duration, Instant};
8
9use serde_json::{json, Value};
10
11use crate::error::{Error, Result};
12use crate::ws::Ws;
13
14const CANDIDATES: &[&str] = &[
16 "chrome-headless-shell",
17 "chromium",
18 "chromium-browser",
19 "google-chrome-stable",
20 "google-chrome",
21];
22
23pub fn find_browser(managed_root: Option<&Path>) -> Result<PathBuf> {
29 if let Some(p) = std::env::var("PROOFSHEET_CHROME")
34 .ok()
35 .filter(|v| !v.trim().is_empty())
36 {
37 let p = PathBuf::from(p.trim());
38 if p.is_file() {
39 return Ok(p);
40 }
41 return Err(Error::Browser(format!(
42 "PROOFSHEET_CHROME points at {}, which is not a file",
43 p.display()
44 )));
45 }
46 if let Some(root) = managed_root {
47 if root.is_dir() {
48 if let Some(found) = find_in_tree(root, "chrome-headless-shell") {
49 return Ok(found);
50 }
51 }
52 }
53 for name in CANDIDATES {
54 if let Some(p) = which(name) {
55 return Ok(p);
56 }
57 }
58 Err(Error::Browser(
59 "no browser found. Set PROOFSHEET_CHROME, or run `proofsheet install-browser`.".into(),
60 ))
61}
62
63fn which(name: &str) -> Option<PathBuf> {
64 let path = std::env::var_os("PATH")?;
65 std::env::split_paths(&path)
66 .map(|d| d.join(name))
67 .find(|c| c.is_file())
68}
69
70fn find_in_tree(root: &Path, name: &str) -> Option<PathBuf> {
71 let entries = std::fs::read_dir(root).ok()?;
72 let mut dirs = Vec::new();
73 for e in entries.flatten() {
74 let p = e.path();
75 if p.is_file() && p.file_name().map(|f| f == name).unwrap_or(false) {
76 return Some(p);
77 }
78 if p.is_dir() {
79 dirs.push(p);
80 }
81 }
82 dirs.iter().find_map(|d| find_in_tree(d, name))
83}
84
85#[derive(Debug, Clone)]
87pub struct LaunchOptions {
88 pub binary: PathBuf,
89 pub port: u16,
90 pub user_data_dir: Option<PathBuf>,
91 pub extra_args: Vec<String>,
92 pub timeout: Duration,
93}
94
95impl LaunchOptions {
96 pub fn new(binary: impl Into<PathBuf>) -> Self {
97 LaunchOptions {
98 binary: binary.into(),
99 port: 0,
102 user_data_dir: None,
103 extra_args: Vec::new(),
104 timeout: Duration::from_secs(30),
105 }
106 }
107}
108
109#[derive(Debug)]
111pub struct Browser {
112 child: Child,
113 ws: Ws,
114 next_id: u64,
115 pub port: u16,
117}
118
119impl Browser {
120 pub fn launch(opts: &LaunchOptions) -> Result<Browser> {
121 let mut cmd = Command::new(&opts.binary);
122 cmd.arg(format!("--remote-debugging-port={}", opts.port))
123 .arg("--headless")
124 .arg("--no-sandbox")
125 .arg("--disable-gpu")
126 .arg("--hide-scrollbars")
127 .arg("--mute-audio")
128 .arg("--no-first-run")
129 .arg("--no-default-browser-check")
130 .arg("--disable-dev-shm-usage")
131 .arg("--force-device-scale-factor=1")
134 .arg("--disable-background-timer-throttling")
136 .arg("--disable-renderer-backgrounding")
137 .arg("--disable-backgrounding-occluded-windows");
138 if let Some(dir) = &opts.user_data_dir {
139 cmd.arg(format!("--user-data-dir={}", dir.display()));
140 }
141 for a in &opts.extra_args {
142 cmd.arg(a);
143 }
144 cmd.arg("about:blank");
145 cmd.stdout(Stdio::null()).stderr(Stdio::piped());
146
147 let mut child = cmd.spawn().map_err(|e| {
148 Error::Browser(format!("could not spawn {}: {e}", opts.binary.display()))
149 })?;
150
151 let stderr = child
152 .stderr
153 .take()
154 .ok_or_else(|| Error::Browser("no stderr pipe".into()))?;
155 let port = match read_devtools_port(stderr, opts.timeout) {
156 Ok(p) => p,
157 Err(e) => {
158 let _ = child.kill();
159 return Err(e);
160 }
161 };
162
163 match attach(port, opts.timeout) {
164 Ok(ws) => Ok(Browser {
165 child,
166 ws,
167 next_id: 0,
168 port,
169 }),
170 Err(e) => {
171 let _ = child.kill();
172 Err(e)
173 }
174 }
175 }
176
177 pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
179 self.next_id += 1;
180 let id = self.next_id;
181 let msg = json!({ "id": id, "method": method, "params": params });
182 self.ws.send_text(&msg.to_string())?;
183 loop {
184 let raw = self.ws.recv_text()?;
185 let v: Value = serde_json::from_str(&raw)?;
186 if v.get("id").and_then(Value::as_u64) != Some(id) {
187 continue; }
189 if let Some(err) = v.get("error") {
190 let message = err
191 .get("message")
192 .and_then(Value::as_str)
193 .unwrap_or("unknown")
194 .to_string();
195 return Err(Error::Cdp {
196 method: method.to_string(),
197 message,
198 });
199 }
200 return Ok(v.get("result").cloned().unwrap_or(Value::Null));
201 }
202 }
203}
204
205impl Drop for Browser {
206 fn drop(&mut self) {
207 self.ws.close();
208 let _ = self.child.kill();
209 let _ = self.child.wait();
210 }
211}
212
213fn read_devtools_port(stderr: impl Read + Send + 'static, timeout: Duration) -> Result<u16> {
217 let (tx, rx) = std::sync::mpsc::channel();
218 std::thread::spawn(move || {
219 let reader = BufReader::new(stderr);
220 for line in reader.lines().map_while(std::result::Result::ok) {
221 if let Some(rest) = line.split("ws://").nth(1) {
222 if let Some(hostport) = rest.split('/').next() {
223 if let Some((_, p)) = hostport.rsplit_once(':') {
224 if let Ok(port) = p.parse::<u16>() {
225 let _ = tx.send(port);
226 return;
227 }
228 }
229 }
230 }
231 }
232 });
233 rx.recv_timeout(timeout)
234 .map_err(|_| Error::Browser("browser did not report a DevTools port".into()))
235}
236
237fn attach(port: u16, timeout: Duration) -> Result<Ws> {
239 let deadline = Instant::now() + timeout;
240 let mut last = String::from("no attempt made");
241 while Instant::now() < deadline {
242 match http_get(port, "/json/list", Duration::from_secs(5)) {
243 Ok(body) => match serde_json::from_str::<Value>(&body) {
244 Ok(Value::Array(targets)) => {
245 let page = targets
246 .iter()
247 .find(|t| t.get("type").and_then(Value::as_str) == Some("page"));
248 if let Some(url) = page
249 .and_then(|t| t.get("webSocketDebuggerUrl"))
250 .and_then(Value::as_str)
251 {
252 return Ws::connect(url, timeout);
253 }
254 last = "no page target yet".into();
255 }
256 Ok(_) => last = "target list was not an array".into(),
257 Err(e) => last = format!("bad target list: {e}"),
258 },
259 Err(e) => last = e.to_string(),
260 }
261 std::thread::sleep(Duration::from_millis(100));
262 }
263 Err(Error::Browser(format!("could not attach: {last}")))
264}
265
266fn http_get(port: u16, path: &str, timeout: Duration) -> Result<String> {
274 use std::io::Write;
275 let mut s = TcpStream::connect(("127.0.0.1", port))?;
276 s.set_read_timeout(Some(timeout))?;
277 s.set_write_timeout(Some(timeout))?;
278 write!(
279 s,
280 "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
281 )?;
282 s.flush()?;
283
284 let mut raw: Vec<u8> = Vec::with_capacity(8192);
285 let mut chunk = [0u8; 8192];
286
287 let head_end = loop {
289 if let Some(i) = find_subslice(&raw, b"\r\n\r\n") {
290 break i;
291 }
292 match s.read(&mut chunk) {
293 Ok(0) => return Err(Error::Shape("http response ended in headers".into())),
294 Ok(n) => raw.extend_from_slice(&chunk[..n]),
295 Err(e) => return Err(Error::Io(e)),
296 }
297 };
298
299 let head = String::from_utf8_lossy(&raw[..head_end]).to_string();
300 let want: Option<usize> = head
301 .split("\r\n")
302 .filter_map(|l| l.split_once(':'))
303 .find(|(k, _)| k.trim().eq_ignore_ascii_case("content-length"))
304 .and_then(|(_, v)| v.trim().parse().ok());
305
306 let body_start = head_end + 4;
307 loop {
308 let have = raw.len() - body_start;
309 match want {
310 Some(n) if have >= n => break,
311 _ => {}
312 }
313 match s.read(&mut chunk) {
314 Ok(0) => break, Ok(n) => raw.extend_from_slice(&chunk[..n]),
316 Err(ref e)
317 if matches!(
318 e.kind(),
319 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
320 ) =>
321 {
322 if want.is_none() && !raw[body_start..].is_empty() {
325 break;
326 }
327 return Err(Error::Shape("timed out reading http body".into()));
328 }
329 Err(e) => return Err(Error::Io(e)),
330 }
331 }
332
333 Ok(String::from_utf8_lossy(&raw[body_start..]).to_string())
334}
335
336fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
337 if needle.is_empty() || hay.len() < needle.len() {
338 return None;
339 }
340 hay.windows(needle.len()).position(|w| w == needle)
341}
342
343#[cfg(test)]
344mod env_tests {
345 #[test]
350 fn empty_env_var_is_not_a_path() {
351 let raw = Some(String::new());
352 let kept = raw.filter(|v: &String| !v.trim().is_empty());
353 assert!(kept.is_none(), "empty string must be discarded");
354
355 let blank = Some(" ".to_string()).filter(|v: &String| !v.trim().is_empty());
356 assert!(blank.is_none(), "whitespace-only must be discarded");
357
358 let real = Some(" /usr/bin/chrome ".to_string())
359 .filter(|v: &String| !v.trim().is_empty())
360 .map(|v| v.trim().to_string());
361 assert_eq!(
362 real.as_deref(),
363 Some("/usr/bin/chrome"),
364 "real path survives, trimmed"
365 );
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn missing_env_binary_is_an_error_not_a_fallback() {
375 std::env::set_var("PROOFSHEET_CHROME", "/nonexistent/definitely/not/here");
379 let r = find_browser(None);
380 std::env::remove_var("PROOFSHEET_CHROME");
381 assert!(matches!(r, Err(Error::Browser(_))));
382 }
383
384 #[test]
385 fn launch_options_default_to_ephemeral_port() {
386 let o = LaunchOptions::new("/bin/true");
387 assert_eq!(o.port, 0);
388 }
389}