Skip to main content

browser_control/cli/
env_resolver.rs

1//! Resolve the `BROWSER_CONTROL` environment variable / `--browser` argument
2//! into a concrete browser endpoint.
3//!
4//! Parsing priority (most-to-least specific):
5//! 1. URL with scheme `ws://`, `wss://`, `http://`, `https://` → [`BrowserSelector::Url`].
6//! 2. Absolute path that exists on disk → [`BrowserSelector::ExecutablePath`].
7//! 3. String that matches a [`Kind`] (case-insensitive) → [`BrowserSelector::Kind`].
8//! 4. Anything else → [`BrowserSelector::Name`].
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{anyhow, bail, Context, Result};
13use futures_util::future::BoxFuture;
14use serde::Serialize;
15
16use crate::detect::{Engine, Installed, Kind};
17use crate::registry::Registry;
18
19/// Parsed form of a `BROWSER_CONTROL` value.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum BrowserSelector {
22    /// Direct CDP/BiDi WebSocket or HTTP URL.
23    Url(url::Url),
24    /// Friendly instance name like `firefox-pikachu`.
25    Name(String),
26    /// Browser kind like `chrome` or `firefox`.
27    Kind(Kind),
28    /// Absolute path to a browser executable (must exist).
29    ExecutablePath(PathBuf),
30}
31
32/// Where the resolved browser came from.
33#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34#[serde(tag = "source", rename_all = "lowercase")]
35pub enum Source {
36    /// An external CDP/BiDi endpoint passed by URL. No registry write.
37    External,
38    /// A registered browser, identified by friendly name.
39    Registered { name: String },
40}
41
42/// Result of resolving a [`BrowserSelector`].
43#[derive(Debug, Clone, Serialize)]
44pub struct ResolvedBrowser {
45    pub endpoint: String,
46    pub engine: Engine,
47    pub source: Source,
48}
49
50/// I/O surface for [`resolve_with`]. Implementations provide HTTP discovery
51/// (`/json/version`) and the list of installed browsers.
52pub trait Resolver: Send + Sync {
53    fn fetch_version<'a>(&'a self, base: &'a str) -> BoxFuture<'a, Result<String>>;
54    fn list_installed(&self) -> Vec<Installed>;
55}
56
57/// Production resolver: real HTTP + on-disk browser detection.
58pub struct DefaultResolver;
59
60impl Resolver for DefaultResolver {
61    fn fetch_version<'a>(&'a self, base: &'a str) -> BoxFuture<'a, Result<String>> {
62        Box::pin(async move {
63            let client = reqwest::Client::builder()
64                .timeout(std::time::Duration::from_secs(2))
65                .build()
66                .context("building reqwest client")?;
67            let url = format!("{}/json/version", base.trim_end_matches('/'));
68            let v: serde_json::Value = client
69                .get(&url)
70                .send()
71                .await
72                .with_context(|| format!("GET {url}"))?
73                .error_for_status()
74                .with_context(|| format!("GET {url}"))?
75                .json()
76                .await
77                .with_context(|| format!("decode JSON from {url}"))?;
78            let ws = v
79                .get("webSocketDebuggerUrl")
80                .and_then(|x| x.as_str())
81                .ok_or_else(|| anyhow!("response from {url} missing webSocketDebuggerUrl"))?
82                .to_string();
83            Ok(ws)
84        })
85    }
86
87    fn list_installed(&self) -> Vec<Installed> {
88        crate::detect::list_installed()
89    }
90}
91
92/// Parse a raw `BROWSER_CONTROL` value into a [`BrowserSelector`].
93pub fn parse(value: &str) -> Result<BrowserSelector> {
94    let v = value.trim();
95    if v.is_empty() {
96        bail!("BROWSER_CONTROL value is empty");
97    }
98
99    let lower = v.to_ascii_lowercase();
100    if lower.starts_with("ws://")
101        || lower.starts_with("wss://")
102        || lower.starts_with("http://")
103        || lower.starts_with("https://")
104    {
105        let u = url::Url::parse(v).with_context(|| format!("parsing URL {v}"))?;
106        return Ok(BrowserSelector::Url(u));
107    }
108
109    let path = Path::new(v);
110    if path.is_absolute() && path.exists() {
111        return Ok(BrowserSelector::ExecutablePath(path.to_path_buf()));
112    }
113
114    if let Some(k) = Kind::parse(v) {
115        return Ok(BrowserSelector::Kind(k));
116    }
117
118    Ok(BrowserSelector::Name(v.to_string()))
119}
120
121/// Heuristic engine detection for a WebSocket URL.
122///
123/// A path beginning with `/session` (WebDriver BiDi) → [`Engine::Bidi`];
124/// anything else (e.g. `/devtools/browser/<id>`) → [`Engine::Cdp`].
125fn engine_from_ws_url(u: &url::Url) -> Engine {
126    let path = u.path();
127    if path.starts_with("/session") || path.contains("/session/") {
128        Engine::Bidi
129    } else {
130        Engine::Cdp
131    }
132}
133
134/// Resolve a selector using the real environment.
135pub async fn resolve(selector: BrowserSelector, registry: &Registry) -> Result<ResolvedBrowser> {
136    resolve_with(selector, registry, &DefaultResolver).await
137}
138
139/// Resolve a selector using an injected [`Resolver`] for I/O.
140pub async fn resolve_with<R: Resolver>(
141    selector: BrowserSelector,
142    registry: &Registry,
143    r: &R,
144) -> Result<ResolvedBrowser> {
145    match selector {
146        BrowserSelector::Url(u) => resolve_url(u, r).await,
147        BrowserSelector::Name(name) => resolve_name(&name, registry),
148        BrowserSelector::Kind(k) => resolve_kind(k, registry),
149        BrowserSelector::ExecutablePath(p) => resolve_path(&p, registry, r),
150    }
151}
152
153async fn resolve_url<R: Resolver>(u: url::Url, r: &R) -> Result<ResolvedBrowser> {
154    match u.scheme() {
155        "ws" | "wss" => Ok(ResolvedBrowser {
156            engine: engine_from_ws_url(&u),
157            endpoint: u.to_string(),
158            source: Source::External,
159        }),
160        "http" | "https" => {
161            let base = u.as_str().trim_end_matches('/').to_string();
162            let ws = r.fetch_version(&base).await?;
163            let ws_url = url::Url::parse(&ws)
164                .with_context(|| format!("parsing webSocketDebuggerUrl {ws}"))?;
165            Ok(ResolvedBrowser {
166                engine: engine_from_ws_url(&ws_url),
167                endpoint: ws,
168                source: Source::External,
169            })
170        }
171        other => bail!("unsupported URL scheme: {other}"),
172    }
173}
174
175fn resolve_name(name: &str, registry: &Registry) -> Result<ResolvedBrowser> {
176    let row = registry
177        .get_by_name(name)
178        .with_context(|| format!("looking up browser {name}"))?
179        .ok_or_else(|| anyhow!("no registered browser named {name}"))?;
180    Ok(ResolvedBrowser {
181        endpoint: row.endpoint,
182        engine: row.engine,
183        source: Source::Registered { name: row.name },
184    })
185}
186
187fn resolve_kind(kind: Kind, registry: &Registry) -> Result<ResolvedBrowser> {
188    let row = registry
189        .first_alive_by_kind(kind)
190        .with_context(|| format!("looking up alive {kind} browser"))?
191        .ok_or_else(|| anyhow!("no running {kind} browser found in registry"))?;
192    Ok(ResolvedBrowser {
193        endpoint: row.endpoint,
194        engine: row.engine,
195        source: Source::Registered { name: row.name },
196    })
197}
198
199fn resolve_path<R: Resolver>(path: &Path, registry: &Registry, r: &R) -> Result<ResolvedBrowser> {
200    let installed = r.list_installed();
201    let kind = installed
202        .iter()
203        .find(|i| i.executable == path)
204        .map(|i| i.kind)
205        .ok_or_else(|| {
206            anyhow!(
207                "executable {} does not match any known installed browser",
208                path.display()
209            )
210        })?;
211    resolve_kind(kind, registry)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::registry::BrowserRow;
218    use std::sync::Mutex;
219
220    fn row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
221        BrowserRow {
222            name: name.to_string(),
223            kind,
224            engine: kind.engine(),
225            pid: std::process::id(),
226            endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/abcd"),
227            port,
228            profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
229            executable: PathBuf::from("/usr/bin/example"),
230            headless: false,
231            started_at: started_at.to_string(),
232        }
233    }
234
235    /// Bind a TCP listener on a random local port and return (listener, port).
236    /// Keep the listener alive for the duration of liveness checks.
237    fn alive_listener() -> (std::net::TcpListener, u16) {
238        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
239        let port = l.local_addr().unwrap().port();
240        (l, port)
241    }
242
243    struct FakeResolver {
244        fetch_version_result: Mutex<Option<Result<String>>>,
245        installed: Vec<Installed>,
246    }
247
248    impl FakeResolver {
249        fn ok(ws: &str) -> Self {
250            Self {
251                fetch_version_result: Mutex::new(Some(Ok(ws.to_string()))),
252                installed: Vec::new(),
253            }
254        }
255        fn empty() -> Self {
256            Self {
257                fetch_version_result: Mutex::new(None),
258                installed: Vec::new(),
259            }
260        }
261        fn with_installed(installed: Vec<Installed>) -> Self {
262            Self {
263                fetch_version_result: Mutex::new(None),
264                installed,
265            }
266        }
267    }
268
269    impl Resolver for FakeResolver {
270        fn fetch_version<'a>(&'a self, _base: &'a str) -> BoxFuture<'a, Result<String>> {
271            let taken = self
272                .fetch_version_result
273                .lock()
274                .unwrap()
275                .take()
276                .unwrap_or_else(|| Err(anyhow!("fetch_version not configured")));
277            Box::pin(async move { taken })
278        }
279        fn list_installed(&self) -> Vec<Installed> {
280            self.installed.clone()
281        }
282    }
283
284    // --- parse() ----------------------------------------------------------
285
286    #[test]
287    fn parse_ws_url() {
288        let s = "ws://x:9222/devtools/browser/abc";
289        match parse(s).unwrap() {
290            BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
291            other => panic!("expected Url, got {other:?}"),
292        }
293    }
294
295    #[test]
296    fn parse_http_url() {
297        match parse("http://x:9222").unwrap() {
298            BrowserSelector::Url(u) => assert_eq!(u.scheme(), "http"),
299            other => panic!("expected Url, got {other:?}"),
300        }
301    }
302
303    #[test]
304    fn parse_absolute_nonexistent_path_falls_through_to_name() {
305        // Absolute path that does not exist on disk falls through to Name per spec.
306        let s = "/non/existent/path/to/nothing-xyz-12345";
307        match parse(s).unwrap() {
308            BrowserSelector::Name(n) => assert_eq!(n, s),
309            other => panic!("expected Name, got {other:?}"),
310        }
311    }
312
313    #[test]
314    fn parse_existing_absolute_path_is_executable_path() {
315        let f = tempfile::NamedTempFile::new().unwrap();
316        let p = f.path().to_path_buf();
317        assert!(p.is_absolute());
318        match parse(p.to_str().unwrap()).unwrap() {
319            BrowserSelector::ExecutablePath(got) => assert_eq!(got, p),
320            other => panic!("expected ExecutablePath, got {other:?}"),
321        }
322    }
323
324    #[test]
325    fn parse_kind_lowercase() {
326        assert_eq!(
327            parse("chrome").unwrap(),
328            BrowserSelector::Kind(Kind::Chrome)
329        );
330    }
331
332    #[test]
333    fn parse_kind_case_insensitive() {
334        assert_eq!(
335            parse("FIREFOX").unwrap(),
336            BrowserSelector::Kind(Kind::Firefox)
337        );
338    }
339
340    #[test]
341    fn parse_friendly_name() {
342        assert_eq!(
343            parse("firefox-pikachu").unwrap(),
344            BrowserSelector::Name("firefox-pikachu".to_string())
345        );
346    }
347
348    // --- resolve_with() ---------------------------------------------------
349
350    #[tokio::test]
351    async fn url_ws_returned_verbatim_cdp_engine() {
352        let reg = Registry::open_in_memory().unwrap();
353        let r = FakeResolver::empty();
354        let sel = parse("ws://127.0.0.1:9222/devtools/browser/abc").unwrap();
355        let got = resolve_with(sel, &reg, &r).await.unwrap();
356        assert_eq!(got.endpoint, "ws://127.0.0.1:9222/devtools/browser/abc");
357        assert_eq!(got.engine, Engine::Cdp);
358        assert_eq!(got.source, Source::External);
359    }
360
361    #[tokio::test]
362    async fn url_ws_session_path_is_bidi() {
363        let reg = Registry::open_in_memory().unwrap();
364        let r = FakeResolver::empty();
365        let sel = parse("ws://127.0.0.1:9222/session/abc123").unwrap();
366        let got = resolve_with(sel, &reg, &r).await.unwrap();
367        assert_eq!(got.engine, Engine::Bidi);
368    }
369
370    #[tokio::test]
371    async fn url_http_invokes_fetch_version() {
372        let reg = Registry::open_in_memory().unwrap();
373        let r = FakeResolver::ok("ws://127.0.0.1:9222/devtools/browser/discovered");
374        let sel = parse("http://127.0.0.1:9222").unwrap();
375        let got = resolve_with(sel, &reg, &r).await.unwrap();
376        assert_eq!(
377            got.endpoint,
378            "ws://127.0.0.1:9222/devtools/browser/discovered"
379        );
380        assert_eq!(got.engine, Engine::Cdp);
381        assert_eq!(got.source, Source::External);
382    }
383
384    #[tokio::test]
385    async fn kind_with_one_running_resolves() {
386        let reg = Registry::open_in_memory().unwrap();
387        let (_listener, port) = alive_listener();
388        let r = row("chrome-foxtrot", Kind::Chrome, port, "2024-06-01T00:00:00Z");
389        reg.insert(&r).unwrap();
390        let fake = FakeResolver::empty();
391        let got = resolve_with(BrowserSelector::Kind(Kind::Chrome), &reg, &fake)
392            .await
393            .unwrap();
394        assert_eq!(got.endpoint, r.endpoint);
395        assert_eq!(got.engine, Engine::Cdp);
396        assert_eq!(
397            got.source,
398            Source::Registered {
399                name: "chrome-foxtrot".to_string()
400            }
401        );
402    }
403
404    #[tokio::test]
405    async fn kind_with_none_running_errors() {
406        let reg = Registry::open_in_memory().unwrap();
407        let fake = FakeResolver::empty();
408        let err = resolve_with(BrowserSelector::Kind(Kind::Chrome), &reg, &fake)
409            .await
410            .unwrap_err();
411        assert!(format!("{err:#}").to_lowercase().contains("chrome"));
412    }
413
414    #[tokio::test]
415    async fn name_lookup_hit() {
416        let reg = Registry::open_in_memory().unwrap();
417        let r = row(
418            "firefox-pikachu",
419            Kind::Firefox,
420            9111,
421            "2024-06-01T00:00:00Z",
422        );
423        reg.insert(&r).unwrap();
424        let fake = FakeResolver::empty();
425        let got = resolve_with(
426            BrowserSelector::Name("firefox-pikachu".to_string()),
427            &reg,
428            &fake,
429        )
430        .await
431        .unwrap();
432        assert_eq!(got.endpoint, r.endpoint);
433        assert_eq!(got.engine, Engine::Bidi);
434        assert_eq!(
435            got.source,
436            Source::Registered {
437                name: "firefox-pikachu".to_string()
438            }
439        );
440    }
441
442    #[tokio::test]
443    async fn name_lookup_miss_errors() {
444        let reg = Registry::open_in_memory().unwrap();
445        let fake = FakeResolver::empty();
446        let err = resolve_with(BrowserSelector::Name("nope".to_string()), &reg, &fake)
447            .await
448            .unwrap_err();
449        assert!(format!("{err:#}").contains("nope"));
450    }
451
452    #[tokio::test]
453    async fn executable_path_maps_to_kind_then_errors_when_no_running() {
454        let reg = Registry::open_in_memory().unwrap();
455        let exe = PathBuf::from("/opt/myorg/chrome");
456        let installed = vec![Installed {
457            kind: Kind::Chrome,
458            executable: exe.clone(),
459            version: "130.0.0.0".to_string(),
460            engine: Engine::Cdp,
461        }];
462        let fake = FakeResolver::with_installed(installed);
463        let err = resolve_with(BrowserSelector::ExecutablePath(exe), &reg, &fake)
464            .await
465            .unwrap_err();
466        // Mapped to Chrome, but nothing running.
467        assert!(format!("{err:#}").to_lowercase().contains("chrome"));
468    }
469
470    #[tokio::test]
471    async fn executable_path_resolves_via_kind_when_running() {
472        let reg = Registry::open_in_memory().unwrap();
473        let (_listener, port) = alive_listener();
474        let r = row("chrome-x", Kind::Chrome, port, "2024-06-01T00:00:00Z");
475        reg.insert(&r).unwrap();
476
477        let exe = PathBuf::from("/opt/myorg/chrome");
478        let installed = vec![Installed {
479            kind: Kind::Chrome,
480            executable: exe.clone(),
481            version: "130.0.0.0".to_string(),
482            engine: Engine::Cdp,
483        }];
484        let fake = FakeResolver::with_installed(installed);
485        let got = resolve_with(BrowserSelector::ExecutablePath(exe), &reg, &fake)
486            .await
487            .unwrap();
488        assert_eq!(got.endpoint, r.endpoint);
489    }
490
491    #[tokio::test]
492    async fn executable_path_unknown_errors() {
493        let reg = Registry::open_in_memory().unwrap();
494        let fake = FakeResolver::with_installed(Vec::new());
495        let err = resolve_with(
496            BrowserSelector::ExecutablePath(PathBuf::from("/totally/unknown")),
497            &reg,
498            &fake,
499        )
500        .await
501        .unwrap_err();
502        assert!(format!("{err:#}").contains("/totally/unknown"));
503    }
504}