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
121fn 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
134pub async fn resolve(selector: BrowserSelector, registry: &Registry) -> Result<ResolvedBrowser> {
136 resolve_with(selector, registry, &DefaultResolver).await
137}
138
139pub 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 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 #[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 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 #[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, ®, &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, ®, &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, ®, &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), ®, &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), ®, &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 ®,
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()), ®, &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), ®, &fake)
464 .await
465 .unwrap_err();
466 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), ®, &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 ®,
498 &fake,
499 )
500 .await
501 .unwrap_err();
502 assert!(format!("{err:#}").contains("/totally/unknown"));
503 }
504}