1use anyhow::{anyhow, Context, Result};
4use serde::Serialize;
5use std::path::PathBuf;
6
7use crate::cli::env_resolver::BrowserSelector;
8use crate::cli::output::print_json;
9use crate::detect::{self, Engine, Installed, Kind};
10use crate::launch::{self, LaunchOpts};
11use crate::paths;
12use crate::registry::{self, BrowserRow, Registry};
13
14#[derive(Debug, Serialize)]
15pub struct StartResult {
16 pub name: String,
17 pub kind: Kind,
18 pub pid: u32,
19 pub engine: Engine,
20 pub endpoint: String,
21 pub profile: PathBuf,
22 pub headless: bool,
23 pub started_at: String,
24 pub reused: bool,
25}
26
27pub async fn run(
28 browser: Option<String>,
29 headless: bool,
30 no_wait: bool,
31 wait_timeout: u64,
32 json: bool,
33) -> Result<()> {
34 let res = ensure_started(browser, headless, no_wait, wait_timeout).await?;
35 emit(&res, json)?;
36 Ok(())
37}
38
39pub async fn ensure_started(
51 browser: Option<String>,
52 headless: bool,
53 no_wait: bool,
54 wait_timeout: u64,
55) -> Result<StartResult> {
56 let installed = detect::list_installed();
57 if installed.is_empty() {
58 anyhow::bail!("no supported browsers installed; run `browser-control list-installed`");
59 }
60
61 let registry = Registry::open()?;
62
63 let resolved_kind: Kind = match browser.as_deref() {
64 None => resolve_kind_when_unspecified(&installed, ®istry)
65 .ok_or_else(|| anyhow!("no chromium-based browser installed"))?,
66 Some(s) => Kind::parse(s).ok_or_else(|| {
67 anyhow!("unknown browser kind `{s}`; valid: chrome, edge, chromium, brave, firefox")
68 })?,
69 };
70
71 let installed_match = installed
72 .iter()
73 .find(|i| i.kind == resolved_kind)
74 .cloned()
75 .ok_or_else(|| {
76 anyhow!(
77 "browser `{}` is not installed on this machine",
78 resolved_kind.as_str()
79 )
80 })?;
81
82 if let Some(row) = registry.first_alive_by_kind(resolved_kind)? {
83 if !no_wait {
84 crate::cli::wait::wait_until_ready(
85 &row.endpoint,
86 row.engine,
87 std::time::Duration::from_secs(wait_timeout),
88 )
89 .await?;
90 }
91 return Ok(to_result(&row, true));
92 }
93
94 let name = registry::naming::generate_default(resolved_kind, ®istry)?;
95 let profile_dir = paths::default_profile_dir(resolved_kind)?;
96 std::fs::create_dir_all(&profile_dir).context("creating profile directory")?;
97 let download_dir = match paths::downloads_staging_dir(resolved_kind) {
98 Ok(d) => Some(d),
99 Err(e) => {
100 tracing::warn!(error = %e, "no download staging dir; leaving profile download prefs");
101 None
102 }
103 };
104 let opts = LaunchOpts {
105 headless,
106 profile_dir: profile_dir.clone(),
107 download_dir,
108 };
109 let handle = launch::launch(&installed_match, opts)
110 .await
111 .with_context(|| format!("launching {}", installed_match.executable.display()))?;
112
113 let row = BrowserRow {
114 name: name.clone(),
115 kind: resolved_kind,
116 engine: handle.engine,
117 pid: handle.pid,
118 endpoint: handle.endpoint.clone(),
119 port: handle.port,
120 profile_dir: handle.profile_dir.clone(),
121 executable: installed_match.executable.clone(),
122 headless,
123 started_at: registry::now_iso8601(),
124 };
125 registry.insert(&row)?;
126 let _pid = handle.forget();
127
128 if !no_wait {
129 crate::cli::wait::wait_until_ready(
130 &row.endpoint,
131 row.engine,
132 std::time::Duration::from_secs(wait_timeout),
133 )
134 .await?;
135 }
136
137 Ok(to_result(&row, false))
138}
139
140pub(crate) fn first_chromium_or_first(installed: &[Installed]) -> Option<Kind> {
141 installed
142 .iter()
143 .find(|i| i.kind.is_chromium())
144 .map(|i| i.kind)
145 .or_else(|| installed.first().map(|i| i.kind))
146}
147
148pub(crate) fn resolve_kind_when_unspecified(
160 installed: &[Installed],
161 registry: &Registry,
162) -> Option<Kind> {
163 if let Some(kind) = kind_from_default_setting(installed, registry) {
164 return Some(kind);
165 }
166 if let Some(kind) = registry
167 .most_recent_alive()
168 .ok()
169 .flatten()
170 .map(|row| row.kind)
171 .filter(|kind| is_installed(installed, *kind))
172 {
173 return Some(kind);
174 }
175 let running = detect::list_running_kinds(installed);
176 installed
177 .iter()
178 .find(|i| running.contains(&i.kind))
179 .map(|i| i.kind)
180 .or_else(|| first_chromium_or_first(installed))
181}
182
183fn is_installed(installed: &[Installed], kind: Kind) -> bool {
184 installed.iter().any(|i| i.kind == kind)
185}
186
187fn kind_from_default_setting(installed: &[Installed], registry: &Registry) -> Option<Kind> {
192 let value = crate::config::load().ok()?.default?;
193 let kind = match crate::cli::env_resolver::parse(&value).ok()? {
194 BrowserSelector::Kind(kind) => Some(kind),
195 BrowserSelector::Name(name) => registry
196 .get_by_name(&name)
197 .ok()
198 .flatten()
199 .map(|row| row.kind)
200 .or_else(|| crate::cli::mcp::kind_from_generated_name(&name)),
201 BrowserSelector::Url(_) | BrowserSelector::ExecutablePath(_) => None,
204 }?;
205 is_installed(installed, kind).then_some(kind)
206}
207
208fn to_result(row: &BrowserRow, reused: bool) -> StartResult {
209 StartResult {
210 name: row.name.clone(),
211 kind: row.kind,
212 pid: row.pid,
213 engine: row.engine,
214 endpoint: row.endpoint.clone(),
215 profile: row.profile_dir.clone(),
216 headless: row.headless,
217 started_at: row.started_at.clone(),
218 reused,
219 }
220}
221
222fn emit(res: &StartResult, json: bool) -> Result<()> {
223 if json {
224 print_json(&mut std::io::stdout(), res)?;
225 } else {
226 let reused = if res.reused { " (reused)" } else { "" };
227 println!("Started {}{}", res.name, reused);
228 println!(" kind: {}", res.kind.as_str());
229 println!(" pid: {}", res.pid);
230 println!(" engine: {:?}", res.engine);
231 println!(" endpoint: {}", res.endpoint);
232 println!(" profile: {}", res.profile.display());
233 }
234 Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::config::{self, Config};
241
242 struct EnvGuard;
243
244 impl Drop for EnvGuard {
245 fn drop(&mut self) {
246 std::env::remove_var("BROWSER_CONTROL_DATA_DIR");
247 std::env::remove_var("BROWSER_CONTROL_CONFIG_DIR");
248 }
249 }
250
251 fn with_tmp_env<R>(f: impl FnOnce() -> R) -> R {
254 let _lock = crate::test_support::ENV_LOCK
255 .lock()
256 .unwrap_or_else(|e| e.into_inner());
257 let data = tempfile::TempDir::new().unwrap();
258 let cfg = tempfile::TempDir::new().unwrap();
259 std::env::set_var("BROWSER_CONTROL_DATA_DIR", data.path());
260 std::env::set_var("BROWSER_CONTROL_CONFIG_DIR", cfg.path());
261 let _guard = EnvGuard;
262 f()
263 }
264
265 fn empty_registry() -> Registry {
266 Registry::open_in_memory().unwrap()
267 }
268
269 fn alive_listener() -> (std::net::TcpListener, u16) {
272 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
273 let port = listener.local_addr().unwrap().port();
274 (listener, port)
275 }
276
277 fn registered_row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
278 BrowserRow {
279 name: name.to_string(),
280 kind,
281 engine: kind.engine(),
282 pid: std::process::id(),
284 endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/{name}"),
285 port,
286 profile_dir: PathBuf::from("/tmp/profile"),
287 executable: PathBuf::from("/usr/bin/example"),
288 headless: false,
289 started_at: started_at.to_string(),
290 }
291 }
292
293 fn spawn_stand_in_process() -> (std::process::Child, PathBuf) {
297 let exe = PathBuf::from(if cfg!(windows) {
298 r"C:\Windows\System32\timeout.exe"
299 } else {
300 "/bin/sleep"
301 });
302 assert!(exe.exists(), "test requires {exe:?} to exist");
303 let child = std::process::Command::new(&exe)
304 .arg("5")
305 .spawn()
306 .expect("spawn stand-in process");
307 (child, exe)
308 }
309
310 fn installed(kind: Kind, executable: PathBuf) -> Installed {
311 Installed {
312 kind,
313 executable,
314 version: "unknown".to_string(),
315 engine: kind.engine(),
316 }
317 }
318
319 #[test]
320 fn resolve_kind_when_unspecified_prefers_running_kind_over_hardcoded_order() {
321 let (mut child, exe) = spawn_stand_in_process();
322
323 let candidates = vec![
327 installed(Kind::Chrome, PathBuf::from("/definitely/not/a/real/chrome")),
328 installed(Kind::Firefox, exe),
329 ];
330
331 let resolved =
332 with_tmp_env(|| resolve_kind_when_unspecified(&candidates, &empty_registry()));
333
334 let _ = child.kill();
335 let _ = child.wait();
336
337 assert_eq!(resolved, Some(Kind::Firefox));
338 }
339
340 #[test]
341 fn resolve_kind_when_unspecified_falls_back_when_nothing_running() {
342 let candidates = vec![
343 installed(
344 Kind::Firefox,
345 PathBuf::from("/definitely/not/a/real/firefox"),
346 ),
347 installed(Kind::Chrome, PathBuf::from("/definitely/not/a/real/chrome")),
348 ];
349 with_tmp_env(|| {
350 let registry = empty_registry();
351 assert_eq!(
352 resolve_kind_when_unspecified(&candidates, ®istry),
353 first_chromium_or_first(&candidates)
354 );
355 assert_eq!(
356 resolve_kind_when_unspecified(&candidates, ®istry),
357 Some(Kind::Chrome)
358 );
359 });
360 }
361
362 #[test]
363 fn resolve_kind_when_unspecified_none_when_nothing_installed() {
364 with_tmp_env(|| {
365 assert_eq!(resolve_kind_when_unspecified(&[], &empty_registry()), None);
366 });
367 }
368
369 #[test]
370 fn persisted_default_kind_wins_over_another_running_browser() {
371 let (mut child, exe) = spawn_stand_in_process();
372
373 let candidates = vec![
378 installed(Kind::Edge, exe),
379 installed(Kind::Brave, PathBuf::from("/definitely/not/a/real/brave")),
380 ];
381
382 let resolved = with_tmp_env(|| {
383 config::save(&Config {
384 default: Some("brave".into()),
385 })
386 .unwrap();
387 resolve_kind_when_unspecified(&candidates, &empty_registry())
388 });
389
390 let _ = child.kill();
391 let _ = child.wait();
392
393 assert_eq!(resolved, Some(Kind::Brave));
394 }
395
396 #[test]
397 fn persisted_default_name_resolves_through_the_registry() {
398 let candidates = vec![
399 installed(Kind::Edge, PathBuf::from("/definitely/not/a/real/edge")),
400 installed(Kind::Brave, PathBuf::from("/definitely/not/a/real/brave")),
401 ];
402
403 let resolved = with_tmp_env(|| {
404 let registry = empty_registry();
405 registry
406 .insert(®istered_row(
407 "brave-jungle",
408 Kind::Brave,
409 1,
410 "2024-01-01T00:00:00Z",
411 ))
412 .unwrap();
413 config::save(&Config {
414 default: Some("brave-jungle".into()),
415 })
416 .unwrap();
417 resolve_kind_when_unspecified(&candidates, ®istry)
418 });
419
420 assert_eq!(resolved, Some(Kind::Brave));
421 }
422
423 #[test]
424 fn default_naming_an_uninstalled_kind_falls_through() {
425 let candidates = vec![installed(
426 Kind::Edge,
427 PathBuf::from("/definitely/not/a/real/edge"),
428 )];
429
430 let resolved = with_tmp_env(|| {
431 config::save(&Config {
432 default: Some("chrome".into()),
433 })
434 .unwrap();
435 resolve_kind_when_unspecified(&candidates, &empty_registry())
436 });
437
438 assert_eq!(resolved, Some(Kind::Edge));
439 }
440
441 #[test]
442 fn live_registered_browser_wins_over_another_running_kind() {
443 let (mut child, exe) = spawn_stand_in_process();
444
445 let candidates = vec![
449 installed(Kind::Edge, exe),
450 installed(Kind::Brave, PathBuf::from("/definitely/not/a/real/brave")),
451 ];
452
453 let (_listener, port) = alive_listener();
454 let resolved = with_tmp_env(|| {
455 let registry = empty_registry();
456 registry
457 .insert(®istered_row(
458 "brave-jungle",
459 Kind::Brave,
460 port,
461 "2024-01-01T00:00:00Z",
462 ))
463 .unwrap();
464 resolve_kind_when_unspecified(&candidates, ®istry)
465 });
466
467 let _ = child.kill();
468 let _ = child.wait();
469
470 assert_eq!(resolved, Some(Kind::Brave));
471 }
472}