Skip to main content

proofsheet_core/
cdp.rs

1//! Launching a headless Chromium and speaking the DevTools Protocol to it.
2
3use 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
14/// Where to look for a browser binary when the caller does not name one.
15const CANDIDATES: &[&str] = &[
16    "chrome-headless-shell",
17    "chromium",
18    "chromium-browser",
19    "google-chrome-stable",
20    "google-chrome",
21];
22
23/// Locate a browser binary.
24///
25/// Order: the `PROOFSHEET_CHROME` environment variable, then the local
26/// managed download, then anything on `PATH`. Explicit beats implicit, and a
27/// pinned local build beats whatever the machine happens to have.
28pub fn find_browser(managed_root: Option<&Path>) -> Result<PathBuf> {
29    // Empty means unset. `export PROOFSHEET_CHROME=$(which chrome)` on a box
30    // without chrome sets it to "", and env::var happily returns Ok(""),
31    // which produced the nonsense "points at , which is not a file" instead
32    // of falling through to discovery.
33    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    // Fall back to the default managed root when the caller does not name
47    // one. Every call site passed None, so a browser installed by
48    // `install-browser` was never actually found -- the install worked and
49    // then the next command still said "no browser".
50    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
78/// Locate a managed headless shell under `root`, if one is installed.
79pub(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/// Options for launching the browser.
107#[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            // 0 asks the OS for a free port, which Chrome reports back on
121            // stderr. Fixed ports collide when runs overlap.
122            port: 0,
123            user_data_dir: None,
124            extra_args: Vec::new(),
125            timeout: Duration::from_secs(30),
126        }
127    }
128}
129
130/// A live browser process plus an attached CDP session.
131#[derive(Debug)]
132pub struct Browser {
133    child: Child,
134    ws: Ws,
135    next_id: u64,
136    /// The port Chrome actually bound, which may differ from the requested one.
137    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            // Keep the browser's own scaling out of it: every scale decision
153            // is made explicitly per device via Emulation.
154            .arg("--force-device-scale-factor=1")
155            // Background throttling would make timing depend on wall clock.
156            .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    /// Issue a CDP command and wait for its matching reply, discarding events.
199    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; // an event, or a reply we are not waiting on
209            }
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
234/// Chrome prints `DevTools listening on ws://127.0.0.1:<port>/...` to stderr
235/// once it is ready. Reading it is how we support `--remote-debugging-port=0`
236/// and avoid guessing whether the browser has finished starting.
237fn 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
258/// Fetch the target list over the plain HTTP endpoint and attach to a page.
259fn 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
287/// A single-shot HTTP/1.1 GET. The DevTools HTTP endpoint is the only thing
288/// we need it for, so it stays deliberately small.
289///
290/// Reads by `Content-Length` rather than to EOF. `read_to_end` on a socket
291/// carrying a read timeout surfaces `WouldBlock`/`TimedOut` as a hard error
292/// even when the full body already arrived, which presented as an opaque
293/// "Resource temporarily unavailable" during bring-up.
294fn 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    // Headers first.
309    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, // clean EOF
336            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                // Timed out with a body already in hand: use what we have
344                // rather than discarding a complete response.
345                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    /// An empty PROOFSHEET_CHROME must fall through to discovery rather than
367    /// being treated as a path. `export PROOFSHEET_CHROME=$(which chrome)` on
368    /// a machine without chrome sets it to "", and the old code reported
369    /// "PROOFSHEET_CHROME points at , which is not a file".
370    #[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        // Pointing at a nonexistent path must fail loudly rather than
397        // silently searching PATH -- substituting a different browser than
398        // the caller named would make runs irreproducible.
399        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}