Skip to main content

browser_control/cli/
start.rs

1//! `start` subcommand: launch a browser and register it.
2
3use 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
39/// Ensure a browser of the requested kind is running and registered.
40///
41/// This is the programmatic form of `browser-control start`: it reuses the
42/// most recent live registry row for the selected kind, otherwise launches a
43/// new browser with the same default profile semantics as the CLI command.
44///
45/// When no kind is requested, selection follows the same order the MCP
46/// server uses at startup (see [`crate::cli::mcp::resolve_browser`]):
47/// the persisted default (`browser-control set default ...`) > the most
48/// recent live registered browser > an installed kind that already has a
49/// live process > the hardcoded chromium-first detection order.
50pub 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, &registry)
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, &registry)?;
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
148/// Selection used by [`ensure_started`] when no `--browser` kind is given.
149///
150/// Order, strongest first:
151/// 1. the persisted default (`browser-control set default <kind|name>`),
152/// 2. the kind of the most recent live registered browser,
153/// 3. an installed kind that already has a live OS process (a browser the
154///    user opened by hand, which browser-control never registered),
155/// 4. the hardcoded chromium-first detection order.
156///
157/// Steps 1–3 only yield a kind that is actually installed, so a stale
158/// default or registry row falls through instead of failing the start.
159pub(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
187/// Kind named by the persisted `default` setting, if it points at an
188/// installed browser. A name selector (`brave-jungle`) resolves through the
189/// registry first, then through the generated-name prefix, so a default that
190/// outlived its browser still selects the right kind.
191fn 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        // A URL or an executable path names an endpoint, not a startable
202        // kind; those are handled by the MCP/CLI resolver, not here.
203        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    /// Point the config and registry at throwaway dirs: selection now reads
252    /// the persisted default and the registry, and must not see the machine's.
253    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    /// A bound-but-unaccepted local port: `registry::liveness` only TCP
270    /// connects, so this is enough to make a row read as alive.
271    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            // This process: alive, so the row counts as live.
283            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    // Stands in for a "browser" that's actually running: we only care that a
294    // live process's exe path matches `Installed.executable`, not that it's
295    // a real browser. Mirrors the technique in detect::mod::tests.
296    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        // Firefox is running (matches the live process's exe) but is listed
324        // after Chrome, and `first_chromium_or_first` would otherwise pick
325        // Chrome first. The running browser must win regardless of order.
326        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, &registry),
353                first_chromium_or_first(&candidates)
354            );
355            assert_eq!(
356                resolve_kind_when_unspecified(&candidates, &registry),
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        // Edge is "running" (its exe matches the live process) and is listed
374        // first, but the user pinned `default = brave`. The pin wins: this is
375        // the case where `browser_start` used to launch Edge next to an open
376        // Brave.
377        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(&registered_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, &registry)
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        // No default pinned, Edge running unregistered, but browser-control
446        // already has a live Brave it started: reuse that rather than
447        // launching a second browser.
448        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(&registered_row(
458                    "brave-jungle",
459                    Kind::Brave,
460                    port,
461                    "2024-01-01T00:00:00Z",
462                ))
463                .unwrap();
464            resolve_kind_when_unspecified(&candidates, &registry)
465        });
466
467        let _ = child.kill();
468        let _ = child.wait();
469
470        assert_eq!(resolved, Some(Kind::Brave));
471    }
472}