1use anyhow::{Context, Result};
4
5use crate::cli::env_resolver::{self, BrowserSelector, ResolvedBrowser, Source};
6use crate::detect::Kind;
7use crate::mcp::server::{run, ServerState, ToolRegistry};
8use crate::registry::{BrowserRow, Registry};
9
10pub async fn run_cli(
12 browser_arg: Option<String>,
13 playwright_version: Option<String>,
14 download_dir: Option<std::path::PathBuf>,
15) -> Result<()> {
16 let download_dir = download_dir.and_then(normalize_download_dir);
17 let resolved = resolve_browser(browser_arg).await?;
18 let sidecar_config = crate::sidecar::SidecarConfig {
19 version: playwright_version,
20 };
21 let mut state = ServerState::with_sidecar_config(resolved, sidecar_config);
22 state.download_dir = download_dir;
23 let tools = ToolRegistry::new();
24 crate::mcp::tools::register_all(&tools);
25 run(state, tools).await
26}
27
28fn normalize_download_dir(dir: std::path::PathBuf) -> Option<std::path::PathBuf> {
33 if dir.as_os_str().is_empty() {
34 return None;
35 }
36 let s = dir.to_string_lossy();
37 let expanded = match (s.strip_prefix('~'), std::env::var_os("HOME")) {
38 (Some(rest), Some(home)) if rest.is_empty() || rest.starts_with('/') => {
39 std::path::PathBuf::from(home).join(rest.trim_start_matches('/'))
40 }
41 _ => dir.clone(),
42 };
43 if expanded.is_absolute() {
44 Some(expanded)
45 } else {
46 tracing::warn!(
47 dir = %dir.display(),
48 "ignoring --download-dir / BROWSER_CONTROL_DOWNLOAD_DIR: not an absolute path"
49 );
50 eprintln!(
51 "browser-control: ignoring download dir {} (not an absolute path); downloads stay in the staging folder",
52 dir.display()
53 );
54 None
55 }
56}
57
58pub async fn resolve_browser(browser_arg: Option<String>) -> Result<ResolvedBrowser> {
68 let registry = Registry::open()?;
69 if let Some(arg) = browser_arg.as_deref().filter(|s| !s.is_empty()) {
70 let sel = env_resolver::parse(arg)?;
71 return resolve_selector_or_start(sel, ®istry).await;
72 }
73 if let Some(value) = crate::config::load()?.default {
74 match resolve_default_browser(&value, ®istry).await {
75 Ok(resolved) => return Ok(resolved),
76 Err(default_err) => {
77 if let Some(row) = registry.most_recent_alive()? {
78 return Ok(resolved_from_row(row));
79 }
80 if let Ok(sel) = env_resolver::parse(&value) {
81 if let Some(kind) = startable_kind_from_selector(&sel) {
82 return start_and_resolve(Some(kind.as_str().to_string()), false, 30)
83 .await
84 .with_context(|| {
85 format!(
86 "default browser `{value}` failed to resolve ({default_err:#}); also failed to start {}",
87 kind.as_str()
88 )
89 });
90 }
91 }
92 return start_and_resolve(None, false, 30).await.with_context(|| {
93 format!(
94 "default browser `{value}` failed to resolve ({default_err:#}); also failed to start a browser"
95 )
96 });
97 }
98 }
99 }
100 if let Some(row) = registry.most_recent_alive()? {
101 return Ok(resolved_from_row(row));
102 }
103 start_and_resolve(None, false, 30).await
104}
105
106async fn resolve_default_browser(value: &str, registry: &Registry) -> Result<ResolvedBrowser> {
107 let sel = env_resolver::parse(value)?;
108 if let BrowserSelector::Name(name) = &sel {
109 if let Some(kind) = stale_default_kind(name, registry)? {
110 rewrite_default_if_unchanged(value, kind)?;
111 return env_resolver::resolve(BrowserSelector::Kind(kind), registry)
112 .await
113 .with_context(|| {
114 format!(
115 "default browser `{name}` is stale; rewrote default to `{}` but failed to resolve a live {} browser",
116 kind.as_str(),
117 kind.as_str()
118 )
119 });
120 }
121 }
122 env_resolver::resolve(sel, registry).await
123}
124
125async fn resolve_selector_or_start(
126 selector: BrowserSelector,
127 registry: &Registry,
128) -> Result<ResolvedBrowser> {
129 match env_resolver::resolve(selector.clone(), registry).await {
130 Ok(resolved) => Ok(resolved),
131 Err(resolve_err) => {
132 if let Some(kind) = startable_kind_from_selector(&selector) {
133 return start_and_resolve(Some(kind.as_str().to_string()), false, 30)
134 .await
135 .with_context(|| {
136 format!(
137 "browser selector failed to resolve ({resolve_err:#}); also failed to start {}",
138 kind.as_str()
139 )
140 });
141 }
142 Err(resolve_err)
143 }
144 }
145}
146
147pub(crate) fn startable_kind_from_selector(selector: &BrowserSelector) -> Option<Kind> {
148 match selector {
149 BrowserSelector::Kind(kind) => Some(*kind),
150 BrowserSelector::Name(name) => kind_from_generated_name(name),
151 BrowserSelector::Url(_) | BrowserSelector::ExecutablePath(_) => None,
152 }
153}
154
155pub async fn start_and_resolve(
156 browser: Option<String>,
157 headless: bool,
158 wait_timeout: u64,
159) -> Result<ResolvedBrowser> {
160 let started = crate::cli::start::ensure_started(browser, headless, false, wait_timeout).await?;
161 Ok(ResolvedBrowser {
162 endpoint: started.endpoint,
163 engine: started.engine,
164 source: Source::Registered { name: started.name },
165 })
166}
167
168fn resolved_from_row(row: BrowserRow) -> ResolvedBrowser {
169 ResolvedBrowser {
170 endpoint: row.endpoint,
171 engine: row.engine,
172 source: Source::Registered { name: row.name },
173 }
174}
175
176fn stale_default_kind(name: &str, registry: &Registry) -> Result<Option<Kind>> {
177 if let Some(row) = registry
178 .get_by_name(name)
179 .with_context(|| format!("looking up default browser {name}"))?
180 {
181 return match crate::registry::liveness(&row) {
182 crate::registry::BrowserLiveness::DeadPid => {
183 registry
184 .delete(&row.name)
185 .with_context(|| format!("pruning stale default browser {}", row.name))?;
186 Ok(Some(row.kind))
187 }
188 crate::registry::BrowserLiveness::Alive
189 | crate::registry::BrowserLiveness::EndpointUnreachable => Ok(None),
190 };
191 }
192
193 Ok(kind_from_generated_name(name))
194}
195
196pub(crate) fn kind_from_generated_name(name: &str) -> Option<Kind> {
197 let (prefix, _) = name.split_once('-')?;
198 Kind::parse(prefix)
199}
200
201fn rewrite_default_if_unchanged(previous: &str, kind: Kind) -> Result<()> {
202 let mut cfg = crate::config::load()?;
203 if cfg.default.as_deref() == Some(previous) {
204 cfg.default = Some(kind.as_str().to_string());
205 crate::config::save(&cfg)
206 .with_context(|| format!("rewriting stale default browser `{previous}`"))?;
207 }
208 Ok(())
209}
210
211pub fn acquire_bidi_lock_if_needed(
216 registry: &crate::registry::Registry,
217 resolved: &ResolvedBrowser,
218) -> Result<Option<crate::registry::BidiLockGuard>> {
219 use crate::detect::Engine;
220 if resolved.engine != Engine::Bidi {
221 return Ok(None);
222 }
223 let name = match &resolved.source {
224 crate::cli::env_resolver::Source::Registered { name } => name.clone(),
225 crate::cli::env_resolver::Source::External => return Ok(None),
226 };
227 let guard = registry.bidi_lock_acquire(&name, std::time::Duration::from_secs(30))?;
228 Ok(Some(guard))
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn download_dir_expands_tilde_and_ignores_relative() {
237 let _g = crate::test_support::ENV_LOCK
238 .lock()
239 .unwrap_or_else(|e| e.into_inner());
240 let home = std::env::var_os("HOME");
241 std::env::set_var("HOME", "/home/u");
242 assert_eq!(
243 normalize_download_dir("~/dl".into()),
244 Some(std::path::PathBuf::from("/home/u/dl"))
245 );
246 assert_eq!(
247 normalize_download_dir("/abs".into()),
248 Some(std::path::PathBuf::from("/abs"))
249 );
250 assert_eq!(normalize_download_dir("rel/dir".into()), None);
251 assert_eq!(normalize_download_dir("~other/x".into()), None);
252 assert_eq!(normalize_download_dir("".into()), None);
253 match home {
254 Some(h) => std::env::set_var("HOME", h),
255 None => std::env::remove_var("HOME"),
256 }
257 }
258 use crate::config::{self, Config};
259 use crate::detect::{Engine, Kind};
260 use crate::registry::{BrowserRow, Registry};
261 use std::path::PathBuf;
262
263 struct EnvGuard;
264
265 impl Drop for EnvGuard {
266 fn drop(&mut self) {
267 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
268 std::env::remove_var("BROWSER_CONTROL_CONFIG_DIR");
269 }
270 }
271
272 struct AliveListener {
273 _listener: std::net::TcpListener,
274 port: u16,
275 }
276
277 fn alive_listener() -> AliveListener {
278 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
279 let port = listener.local_addr().unwrap().port();
280 AliveListener {
281 _listener: listener,
282 port,
283 }
284 }
285
286 fn row(name: &str, kind: Kind, port: u16, pid: u32, started_at: &str) -> BrowserRow {
287 BrowserRow {
288 name: name.to_string(),
289 kind,
290 engine: kind.engine(),
291 pid,
292 endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/{name}"),
293 port,
294 profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
295 executable: PathBuf::from("/usr/bin/example"),
296 headless: false,
297 started_at: started_at.to_string(),
298 }
299 }
300
301 fn with_tmp_env<R>(f: impl FnOnce() -> R) -> R {
302 let _lock = crate::test_support::ENV_LOCK
303 .lock()
304 .unwrap_or_else(|e| e.into_inner());
305 let data = tempfile::TempDir::new().unwrap();
306 let cfg = tempfile::TempDir::new().unwrap();
307 std::env::set_var("BROWSER_CONTROL_DATA_DIR", data.path());
308 std::env::set_var("BROWSER_CONTROL_CONFIG_DIR", cfg.path());
309 let _guard = EnvGuard;
310 f()
311 }
312
313 #[test]
314 fn stale_named_default_rewrites_to_kind_and_resolves_live_same_kind() {
315 with_tmp_env(|| {
316 let live = alive_listener();
317 let reg = Registry::open().unwrap();
318 reg.insert(&row(
319 "brave-cosmos",
320 Kind::Brave,
321 9,
322 99_999_999,
323 "2024-01-01T00:00:00Z",
324 ))
325 .unwrap();
326 let live_row = row(
327 "brave-spruce",
328 Kind::Brave,
329 live.port,
330 std::process::id(),
331 "2024-01-02T00:00:00Z",
332 );
333 reg.insert(&live_row).unwrap();
334 config::save(&Config {
335 default: Some("brave-cosmos".into()),
336 })
337 .unwrap();
338 drop(reg);
339
340 let rt = tokio::runtime::Runtime::new().unwrap();
341 let got = rt.block_on(resolve_browser(None)).unwrap();
342 assert_eq!(got.endpoint, live_row.endpoint);
343 assert_eq!(got.engine, Engine::Cdp);
344 assert_eq!(
345 got.source,
346 env_resolver::Source::Registered {
347 name: "brave-spruce".into()
348 }
349 );
350 assert_eq!(config::load().unwrap().default.as_deref(), Some("brave"));
351 let reg = Registry::open().unwrap();
352 assert!(reg.get_by_name("brave-cosmos").unwrap().is_none());
353 });
354 }
355
356 #[test]
357 fn missing_generated_default_rewrites_to_kind_and_resolves_live_same_kind() {
358 with_tmp_env(|| {
359 let live = alive_listener();
360 let reg = Registry::open().unwrap();
361 let live_row = row(
362 "brave-spruce",
363 Kind::Brave,
364 live.port,
365 std::process::id(),
366 "2024-01-02T00:00:00Z",
367 );
368 reg.insert(&live_row).unwrap();
369 config::save(&Config {
370 default: Some("brave-cosmos".into()),
371 })
372 .unwrap();
373 drop(reg);
374
375 let rt = tokio::runtime::Runtime::new().unwrap();
376 let got = rt.block_on(resolve_browser(None)).unwrap();
377 assert_eq!(got.endpoint, live_row.endpoint);
378 assert_eq!(
379 got.source,
380 env_resolver::Source::Registered {
381 name: "brave-spruce".into()
382 }
383 );
384 assert_eq!(config::load().unwrap().default.as_deref(), Some("brave"));
385 });
386 }
387}