1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum BrowserSelector {
22 Url(url::Url),
24 Name(String),
26 Kind(Kind),
28 ExecutablePath(PathBuf),
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34#[serde(tag = "source", rename_all = "lowercase")]
35pub enum Source {
36 External,
38 Registered { name: String },
40}
41
42#[derive(Debug, Clone, Serialize)]
44pub struct ResolvedBrowser {
45 pub endpoint: String,
46 pub engine: Engine,
47 pub source: Source,
48}
49
50pub 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
57pub 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
92pub 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#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct BrowserTarget {
129 pub browser: BrowserSelector,
130 pub tab: Option<String>,
131}
132
133pub fn parse_target(value: &str) -> Result<BrowserTarget> {
146 let v = value.trim();
147 if v.is_empty() {
148 bail!("BROWSER_CONTROL value is empty");
149 }
150 let lower = v.to_ascii_lowercase();
151 if lower.starts_with("ws://")
152 || lower.starts_with("wss://")
153 || lower.starts_with("http://")
154 || lower.starts_with("https://")
155 {
156 let u = url::Url::parse(v).with_context(|| format!("parsing URL {v}"))?;
157 return Ok(BrowserTarget {
158 browser: BrowserSelector::Url(u),
159 tab: None,
160 });
161 }
162 let path = Path::new(v);
163 if path.is_absolute() && path.exists() {
164 return Ok(BrowserTarget {
165 browser: BrowserSelector::ExecutablePath(path.to_path_buf()),
166 tab: None,
167 });
168 }
169 if let Some(idx) = v.find('/') {
173 if idx > 0 {
174 let (left, right) = v.split_at(idx);
175 let tab = &right[1..];
176 validate_tab_name(tab)?;
177 return Ok(BrowserTarget {
178 browser: parse(left)?,
179 tab: Some(tab.to_string()),
180 });
181 }
182 }
183 Ok(BrowserTarget {
184 browser: parse(v)?,
185 tab: None,
186 })
187}
188
189pub fn validate_tab_name(name: &str) -> Result<()> {
193 if name.is_empty() {
194 bail!("tab name is empty");
195 }
196 if name.len() > 64 {
197 bail!("tab name `{name}` exceeds 64 characters");
198 }
199 if name.starts_with('_') {
200 bail!("tab name `{name}` starts with `_` (reserved namespace)");
201 }
202 if !name
203 .bytes()
204 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
205 {
206 bail!(
207 "tab name `{name}` contains invalid characters \
208 (allowed: a-z, 0-9, `-`, `_`)"
209 );
210 }
211 Ok(())
212}
213
214fn engine_from_ws_url(u: &url::Url) -> Engine {
219 let path = u.path();
220 if path.starts_with("/session") || path.contains("/session/") {
221 Engine::Bidi
222 } else {
223 Engine::Cdp
224 }
225}
226
227pub async fn resolve(selector: BrowserSelector, registry: &Registry) -> Result<ResolvedBrowser> {
229 resolve_with(selector, registry, &DefaultResolver).await
230}
231
232pub async fn resolve_with<R: Resolver>(
234 selector: BrowserSelector,
235 registry: &Registry,
236 r: &R,
237) -> Result<ResolvedBrowser> {
238 match selector {
239 BrowserSelector::Url(u) => resolve_url(u, r).await,
240 BrowserSelector::Name(name) => resolve_name(&name, registry),
241 BrowserSelector::Kind(k) => resolve_kind(k, registry),
242 BrowserSelector::ExecutablePath(p) => resolve_path(&p, registry, r),
243 }
244}
245
246async fn resolve_url<R: Resolver>(u: url::Url, r: &R) -> Result<ResolvedBrowser> {
247 match u.scheme() {
248 "ws" | "wss" => Ok(ResolvedBrowser {
249 engine: engine_from_ws_url(&u),
250 endpoint: u.to_string(),
251 source: Source::External,
252 }),
253 "http" | "https" => {
254 let base = u.as_str().trim_end_matches('/').to_string();
255 let ws = r.fetch_version(&base).await?;
256 let ws_url = url::Url::parse(&ws)
257 .with_context(|| format!("parsing webSocketDebuggerUrl {ws}"))?;
258 Ok(ResolvedBrowser {
259 engine: engine_from_ws_url(&ws_url),
260 endpoint: ws,
261 source: Source::External,
262 })
263 }
264 other => bail!("unsupported URL scheme: {other}"),
265 }
266}
267
268fn resolve_name(name: &str, registry: &Registry) -> Result<ResolvedBrowser> {
269 let row = registry
270 .get_by_name(name)
271 .with_context(|| format!("looking up browser {name}"))?
272 .ok_or_else(|| anyhow!("no registered browser named {name}"))?;
273 match crate::registry::liveness(&row) {
274 crate::registry::BrowserLiveness::Alive => {}
275 crate::registry::BrowserLiveness::DeadPid => {
276 registry
277 .delete(&row.name)
278 .with_context(|| format!("pruning stale browser {}", row.name))?;
279 bail!(
280 "registered browser `{}` is stale (pid {} is no longer running); \
281 pruned it from the registry. Select a live browser by kind with `-b {}` or \
282 update the default with `browser-control set default {}`",
283 row.name,
284 row.pid,
285 row.kind.as_str(),
286 row.kind.as_str()
287 );
288 }
289 crate::registry::BrowserLiveness::EndpointUnreachable => {
290 bail!(
291 "registered browser `{}` is not reachable at {} (pid {} still exists); \
292 keeping the registry row because this can be transient. Select a live browser \
293 by kind with `-b {}` or update the default with `browser-control set default {}`",
294 row.name,
295 row.endpoint,
296 row.pid,
297 row.kind.as_str(),
298 row.kind.as_str()
299 );
300 }
301 }
302 Ok(ResolvedBrowser {
303 endpoint: row.endpoint,
304 engine: row.engine,
305 source: Source::Registered { name: row.name },
306 })
307}
308
309fn resolve_kind(kind: Kind, registry: &Registry) -> Result<ResolvedBrowser> {
310 let row = registry
311 .first_alive_by_kind(kind)
312 .with_context(|| format!("looking up alive {kind} browser"))?
313 .ok_or_else(|| anyhow!("no running {kind} browser found in registry"))?;
314 Ok(ResolvedBrowser {
315 endpoint: row.endpoint,
316 engine: row.engine,
317 source: Source::Registered { name: row.name },
318 })
319}
320
321fn resolve_path<R: Resolver>(path: &Path, registry: &Registry, r: &R) -> Result<ResolvedBrowser> {
322 let installed = r.list_installed();
323 let kind = installed
324 .iter()
325 .find(|i| i.executable == path)
326 .map(|i| i.kind)
327 .ok_or_else(|| {
328 anyhow!(
329 "executable {} does not match any known installed browser",
330 path.display()
331 )
332 })?;
333 resolve_kind(kind, registry)
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::registry::BrowserRow;
340 use std::sync::Mutex;
341
342 fn row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
343 BrowserRow {
344 name: name.to_string(),
345 kind,
346 engine: kind.engine(),
347 pid: std::process::id(),
348 endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/abcd"),
349 port,
350 profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
351 executable: PathBuf::from("/usr/bin/example"),
352 headless: false,
353 started_at: started_at.to_string(),
354 }
355 }
356
357 fn alive_listener() -> (std::net::TcpListener, u16) {
360 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
361 let port = l.local_addr().unwrap().port();
362 (l, port)
363 }
364
365 struct FakeResolver {
366 fetch_version_result: Mutex<Option<Result<String>>>,
367 installed: Vec<Installed>,
368 }
369
370 impl FakeResolver {
371 fn ok(ws: &str) -> Self {
372 Self {
373 fetch_version_result: Mutex::new(Some(Ok(ws.to_string()))),
374 installed: Vec::new(),
375 }
376 }
377 fn empty() -> Self {
378 Self {
379 fetch_version_result: Mutex::new(None),
380 installed: Vec::new(),
381 }
382 }
383 fn with_installed(installed: Vec<Installed>) -> Self {
384 Self {
385 fetch_version_result: Mutex::new(None),
386 installed,
387 }
388 }
389 }
390
391 impl Resolver for FakeResolver {
392 fn fetch_version<'a>(&'a self, _base: &'a str) -> BoxFuture<'a, Result<String>> {
393 let taken = self
394 .fetch_version_result
395 .lock()
396 .unwrap()
397 .take()
398 .unwrap_or_else(|| Err(anyhow!("fetch_version not configured")));
399 Box::pin(async move { taken })
400 }
401 fn list_installed(&self) -> Vec<Installed> {
402 self.installed.clone()
403 }
404 }
405
406 #[test]
409 fn parse_ws_url() {
410 let s = "ws://x:9222/devtools/browser/abc";
411 match parse(s).unwrap() {
412 BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
413 other => panic!("expected Url, got {other:?}"),
414 }
415 }
416
417 #[test]
418 fn parse_http_url() {
419 match parse("http://x:9222").unwrap() {
420 BrowserSelector::Url(u) => assert_eq!(u.scheme(), "http"),
421 other => panic!("expected Url, got {other:?}"),
422 }
423 }
424
425 #[test]
426 fn parse_absolute_nonexistent_path_falls_through_to_name() {
427 let s = "/non/existent/path/to/nothing-xyz-12345";
429 match parse(s).unwrap() {
430 BrowserSelector::Name(n) => assert_eq!(n, s),
431 other => panic!("expected Name, got {other:?}"),
432 }
433 }
434
435 #[test]
436 fn parse_existing_absolute_path_is_executable_path() {
437 let f = tempfile::NamedTempFile::new().unwrap();
438 let p = f.path().to_path_buf();
439 assert!(p.is_absolute());
440 match parse(p.to_str().unwrap()).unwrap() {
441 BrowserSelector::ExecutablePath(got) => assert_eq!(got, p),
442 other => panic!("expected ExecutablePath, got {other:?}"),
443 }
444 }
445
446 #[test]
447 fn parse_kind_lowercase() {
448 assert_eq!(
449 parse("chrome").unwrap(),
450 BrowserSelector::Kind(Kind::Chrome)
451 );
452 }
453
454 #[test]
455 fn parse_kind_case_insensitive() {
456 assert_eq!(
457 parse("FIREFOX").unwrap(),
458 BrowserSelector::Kind(Kind::Firefox)
459 );
460 }
461
462 #[test]
463 fn parse_friendly_name() {
464 assert_eq!(
465 parse("firefox-pikachu").unwrap(),
466 BrowserSelector::Name("firefox-pikachu".to_string())
467 );
468 }
469
470 #[test]
473 fn parse_target_bare_name_has_no_tab() {
474 let t = parse_target("firefox-pikachu").unwrap();
475 assert_eq!(
476 t.browser,
477 BrowserSelector::Name("firefox-pikachu".to_string())
478 );
479 assert!(t.tab.is_none());
480 }
481
482 #[test]
483 fn parse_target_name_slash_tab_splits() {
484 let t = parse_target("brave-twilight/scrape-cart").unwrap();
485 assert_eq!(
486 t.browser,
487 BrowserSelector::Name("brave-twilight".to_string())
488 );
489 assert_eq!(t.tab.as_deref(), Some("scrape-cart"));
490 }
491
492 #[test]
493 fn parse_target_kind_slash_tab_splits() {
494 let t = parse_target("chrome/login").unwrap();
495 assert_eq!(t.browser, BrowserSelector::Kind(Kind::Chrome));
496 assert_eq!(t.tab.as_deref(), Some("login"));
497 }
498
499 #[test]
500 fn parse_target_url_keeps_slashes_in_url() {
501 let s = "ws://127.0.0.1:9222/devtools/browser/abc";
502 let t = parse_target(s).unwrap();
503 match t.browser {
504 BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
505 other => panic!("expected Url, got {other:?}"),
506 }
507 assert!(t.tab.is_none());
508 }
509
510 #[test]
511 fn parse_target_absolute_nonexistent_path_falls_through_to_name_with_slashes() {
512 let s = "/non/existent/path/to/nothing-xyz-12345";
515 let t = parse_target(s).unwrap();
516 assert_eq!(t.browser, BrowserSelector::Name(s.to_string()));
517 assert!(t.tab.is_none());
518 }
519
520 #[test]
521 fn parse_target_existing_absolute_path_with_no_slash_in_tab_slot() {
522 let f = tempfile::NamedTempFile::new().unwrap();
523 let t = parse_target(f.path().to_str().unwrap()).unwrap();
524 match &t.browser {
525 BrowserSelector::ExecutablePath(p) => assert_eq!(p, f.path()),
526 other => panic!("expected ExecutablePath, got {other:?}"),
527 }
528 assert!(t.tab.is_none());
529 }
530
531 #[test]
532 fn parse_target_validates_tab_name() {
533 assert!(parse_target("brave/UPPER").is_err()); assert!(parse_target("brave/has space").is_err()); assert!(parse_target("brave/_internal").is_err()); assert!(parse_target("brave/").is_err()); }
538
539 #[test]
542 fn validate_tab_name_accepts_canonical_forms() {
543 validate_tab_name("scrape-cart").unwrap();
544 validate_tab_name("a").unwrap();
545 validate_tab_name("checkout-flow-2").unwrap();
546 validate_tab_name("page_a").unwrap();
547 validate_tab_name("042").unwrap();
548 }
549
550 #[test]
551 fn validate_tab_name_rejects_invalid() {
552 assert!(validate_tab_name("").is_err());
553 assert!(validate_tab_name("a".repeat(65).as_str()).is_err());
554 assert!(validate_tab_name("_scratch").is_err());
555 assert!(validate_tab_name("Tab").is_err());
556 assert!(validate_tab_name("a/b").is_err());
557 assert!(validate_tab_name("a b").is_err());
558 }
559
560 #[tokio::test]
563 async fn url_ws_returned_verbatim_cdp_engine() {
564 let reg = Registry::open_in_memory().unwrap();
565 let r = FakeResolver::empty();
566 let sel = parse("ws://127.0.0.1:9222/devtools/browser/abc").unwrap();
567 let got = resolve_with(sel, ®, &r).await.unwrap();
568 assert_eq!(got.endpoint, "ws://127.0.0.1:9222/devtools/browser/abc");
569 assert_eq!(got.engine, Engine::Cdp);
570 assert_eq!(got.source, Source::External);
571 }
572
573 #[tokio::test]
574 async fn url_ws_session_path_is_bidi() {
575 let reg = Registry::open_in_memory().unwrap();
576 let r = FakeResolver::empty();
577 let sel = parse("ws://127.0.0.1:9222/session/abc123").unwrap();
578 let got = resolve_with(sel, ®, &r).await.unwrap();
579 assert_eq!(got.engine, Engine::Bidi);
580 }
581
582 #[tokio::test]
583 async fn url_http_invokes_fetch_version() {
584 let reg = Registry::open_in_memory().unwrap();
585 let r = FakeResolver::ok("ws://127.0.0.1:9222/devtools/browser/discovered");
586 let sel = parse("http://127.0.0.1:9222").unwrap();
587 let got = resolve_with(sel, ®, &r).await.unwrap();
588 assert_eq!(
589 got.endpoint,
590 "ws://127.0.0.1:9222/devtools/browser/discovered"
591 );
592 assert_eq!(got.engine, Engine::Cdp);
593 assert_eq!(got.source, Source::External);
594 }
595
596 #[tokio::test]
597 async fn kind_with_one_running_resolves() {
598 let reg = Registry::open_in_memory().unwrap();
599 let (_listener, port) = alive_listener();
600 let r = row("chrome-foxtrot", Kind::Chrome, port, "2024-06-01T00:00:00Z");
601 reg.insert(&r).unwrap();
602 let fake = FakeResolver::empty();
603 let got = resolve_with(BrowserSelector::Kind(Kind::Chrome), ®, &fake)
604 .await
605 .unwrap();
606 assert_eq!(got.endpoint, r.endpoint);
607 assert_eq!(got.engine, Engine::Cdp);
608 assert_eq!(
609 got.source,
610 Source::Registered {
611 name: "chrome-foxtrot".to_string()
612 }
613 );
614 }
615
616 #[tokio::test]
617 async fn kind_with_none_running_errors() {
618 let reg = Registry::open_in_memory().unwrap();
619 let fake = FakeResolver::empty();
620 let err = resolve_with(BrowserSelector::Kind(Kind::Chrome), ®, &fake)
621 .await
622 .unwrap_err();
623 assert!(format!("{err:#}").to_lowercase().contains("chrome"));
624 }
625
626 #[tokio::test]
627 async fn name_lookup_hit() {
628 let reg = Registry::open_in_memory().unwrap();
629 let (_listener, port) = alive_listener();
630 let r = row(
631 "firefox-pikachu",
632 Kind::Firefox,
633 port,
634 "2024-06-01T00:00:00Z",
635 );
636 reg.insert(&r).unwrap();
637 let fake = FakeResolver::empty();
638 let got = resolve_with(
639 BrowserSelector::Name("firefox-pikachu".to_string()),
640 ®,
641 &fake,
642 )
643 .await
644 .unwrap();
645 assert_eq!(got.endpoint, r.endpoint);
646 assert_eq!(got.engine, Engine::Bidi);
647 assert_eq!(
648 got.source,
649 Source::Registered {
650 name: "firefox-pikachu".to_string()
651 }
652 );
653 }
654
655 #[tokio::test]
656 async fn name_lookup_stale_prunes_and_errors() {
657 let reg = Registry::open_in_memory().unwrap();
658 let mut r = row("brave-cosmos", Kind::Brave, 9111, "2024-06-01T00:00:00Z");
659 r.pid = 99_999_999;
660 reg.insert(&r).unwrap();
661 let fake = FakeResolver::empty();
662 let err = resolve_with(
663 BrowserSelector::Name("brave-cosmos".to_string()),
664 ®,
665 &fake,
666 )
667 .await
668 .unwrap_err();
669 let msg = format!("{err:#}");
670 assert!(msg.contains("brave-cosmos"), "got: {msg}");
671 assert!(msg.contains("stale"), "got: {msg}");
672 assert!(msg.contains("set default brave"), "got: {msg}");
673 assert!(reg.get_by_name("brave-cosmos").unwrap().is_none());
674 }
675
676 #[tokio::test]
677 async fn name_lookup_endpoint_unreachable_is_retained() {
678 let reg = Registry::open_in_memory().unwrap();
679 let r = row("brave-cumin", Kind::Brave, 9111, "2024-06-01T00:00:00Z");
680 reg.insert(&r).unwrap();
681 let fake = FakeResolver::empty();
682 let err = resolve_with(
683 BrowserSelector::Name("brave-cumin".to_string()),
684 ®,
685 &fake,
686 )
687 .await
688 .unwrap_err();
689 let msg = format!("{err:#}");
690 assert!(msg.contains("brave-cumin"), "got: {msg}");
691 assert!(msg.contains("not reachable"), "got: {msg}");
692 assert!(msg.contains("keeping the registry row"), "got: {msg}");
693 assert!(reg.get_by_name("brave-cumin").unwrap().is_some());
694 }
695
696 #[tokio::test]
697 async fn name_lookup_miss_errors() {
698 let reg = Registry::open_in_memory().unwrap();
699 let fake = FakeResolver::empty();
700 let err = resolve_with(BrowserSelector::Name("nope".to_string()), ®, &fake)
701 .await
702 .unwrap_err();
703 assert!(format!("{err:#}").contains("nope"));
704 }
705
706 #[tokio::test]
707 async fn executable_path_maps_to_kind_then_errors_when_no_running() {
708 let reg = Registry::open_in_memory().unwrap();
709 let exe = PathBuf::from("/opt/myorg/chrome");
710 let installed = vec![Installed {
711 kind: Kind::Chrome,
712 executable: exe.clone(),
713 version: "130.0.0.0".to_string(),
714 engine: Engine::Cdp,
715 }];
716 let fake = FakeResolver::with_installed(installed);
717 let err = resolve_with(BrowserSelector::ExecutablePath(exe), ®, &fake)
718 .await
719 .unwrap_err();
720 assert!(format!("{err:#}").to_lowercase().contains("chrome"));
722 }
723
724 #[tokio::test]
725 async fn executable_path_resolves_via_kind_when_running() {
726 let reg = Registry::open_in_memory().unwrap();
727 let (_listener, port) = alive_listener();
728 let r = row("chrome-x", Kind::Chrome, port, "2024-06-01T00:00:00Z");
729 reg.insert(&r).unwrap();
730
731 let exe = PathBuf::from("/opt/myorg/chrome");
732 let installed = vec![Installed {
733 kind: Kind::Chrome,
734 executable: exe.clone(),
735 version: "130.0.0.0".to_string(),
736 engine: Engine::Cdp,
737 }];
738 let fake = FakeResolver::with_installed(installed);
739 let got = resolve_with(BrowserSelector::ExecutablePath(exe), ®, &fake)
740 .await
741 .unwrap();
742 assert_eq!(got.endpoint, r.endpoint);
743 }
744
745 #[tokio::test]
746 async fn executable_path_unknown_errors() {
747 let reg = Registry::open_in_memory().unwrap();
748 let fake = FakeResolver::with_installed(Vec::new());
749 let err = resolve_with(
750 BrowserSelector::ExecutablePath(PathBuf::from("/totally/unknown")),
751 ®,
752 &fake,
753 )
754 .await
755 .unwrap_err();
756 assert!(format!("{err:#}").contains("/totally/unknown"));
757 }
758}