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