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::output::print_json;
8use crate::detect::{self, Engine, Installed, Kind};
9use crate::launch::{self, LaunchOpts};
10use crate::paths;
11use crate::registry::{self, BrowserRow, Registry};
12
13#[derive(Debug, Serialize)]
14pub struct StartResult {
15    pub name: String,
16    pub kind: Kind,
17    pub pid: u32,
18    pub engine: Engine,
19    pub endpoint: String,
20    pub profile: PathBuf,
21    pub headless: bool,
22    pub started_at: String,
23    pub reused: bool,
24}
25
26pub async fn run(
27    browser: Option<String>,
28    headless: bool,
29    no_wait: bool,
30    wait_timeout: u64,
31    json: bool,
32) -> Result<()> {
33    let res = ensure_started(browser, headless, no_wait, wait_timeout).await?;
34    emit(&res, json)?;
35    Ok(())
36}
37
38/// Ensure a browser of the requested kind is running and registered.
39///
40/// This is the programmatic form of `browser-control start`: it reuses the
41/// most recent live registry row for the selected kind, otherwise launches a
42/// new browser with the same default profile semantics as the CLI command.
43///
44/// When no kind is requested, an installed browser the user already has
45/// running (even one browser-control never launched or registered) is
46/// preferred over the hardcoded detection order — so a manually-opened
47/// browser wins over blindly starting whichever kind is first on the
48/// platform's candidate list.
49pub async fn ensure_started(
50    browser: Option<String>,
51    headless: bool,
52    no_wait: bool,
53    wait_timeout: u64,
54) -> Result<StartResult> {
55    let installed = detect::list_installed();
56    if installed.is_empty() {
57        anyhow::bail!("no supported browsers installed; run `browser-control list-installed`");
58    }
59
60    let resolved_kind: Kind = match browser.as_deref() {
61        None => resolve_kind_when_unspecified(&installed)
62            .ok_or_else(|| anyhow!("no chromium-based browser installed"))?,
63        Some(s) => Kind::parse(s).ok_or_else(|| {
64            anyhow!("unknown browser kind `{s}`; valid: chrome, edge, chromium, brave, firefox")
65        })?,
66    };
67
68    let installed_match = installed
69        .iter()
70        .find(|i| i.kind == resolved_kind)
71        .cloned()
72        .ok_or_else(|| {
73            anyhow!(
74                "browser `{}` is not installed on this machine",
75                resolved_kind.as_str()
76            )
77        })?;
78
79    let registry = Registry::open()?;
80    if let Some(row) = registry.first_alive_by_kind(resolved_kind)? {
81        if !no_wait {
82            crate::cli::wait::wait_until_ready(
83                &row.endpoint,
84                row.engine,
85                std::time::Duration::from_secs(wait_timeout),
86            )
87            .await?;
88        }
89        return Ok(to_result(&row, true));
90    }
91
92    let name = registry::naming::generate_default(resolved_kind, &registry)?;
93    let profile_dir = paths::default_profile_dir(resolved_kind)?;
94    std::fs::create_dir_all(&profile_dir).context("creating profile directory")?;
95    let opts = LaunchOpts {
96        headless,
97        profile_dir: profile_dir.clone(),
98    };
99    let handle = launch::launch(&installed_match, opts)
100        .await
101        .with_context(|| format!("launching {}", installed_match.executable.display()))?;
102
103    let row = BrowserRow {
104        name: name.clone(),
105        kind: resolved_kind,
106        engine: handle.engine,
107        pid: handle.pid,
108        endpoint: handle.endpoint.clone(),
109        port: handle.port,
110        profile_dir: handle.profile_dir.clone(),
111        executable: installed_match.executable.clone(),
112        headless,
113        started_at: registry::now_iso8601(),
114    };
115    registry.insert(&row)?;
116    let _pid = handle.forget();
117
118    if !no_wait {
119        crate::cli::wait::wait_until_ready(
120            &row.endpoint,
121            row.engine,
122            std::time::Duration::from_secs(wait_timeout),
123        )
124        .await?;
125    }
126
127    Ok(to_result(&row, false))
128}
129
130pub(crate) fn first_chromium_or_first(installed: &[Installed]) -> Option<Kind> {
131    installed
132        .iter()
133        .find(|i| i.kind.is_chromium())
134        .map(|i| i.kind)
135        .or_else(|| installed.first().map(|i| i.kind))
136}
137
138/// Selection used by [`ensure_started`] when no `--browser` kind is given:
139/// prefer an installed kind that already has a live process over the
140/// hardcoded chromium-first fallback order.
141pub(crate) fn resolve_kind_when_unspecified(installed: &[Installed]) -> Option<Kind> {
142    let running = detect::list_running_kinds(installed);
143    installed
144        .iter()
145        .find(|i| running.contains(&i.kind))
146        .map(|i| i.kind)
147        .or_else(|| first_chromium_or_first(installed))
148}
149
150fn to_result(row: &BrowserRow, reused: bool) -> StartResult {
151    StartResult {
152        name: row.name.clone(),
153        kind: row.kind,
154        pid: row.pid,
155        engine: row.engine,
156        endpoint: row.endpoint.clone(),
157        profile: row.profile_dir.clone(),
158        headless: row.headless,
159        started_at: row.started_at.clone(),
160        reused,
161    }
162}
163
164fn emit(res: &StartResult, json: bool) -> Result<()> {
165    if json {
166        print_json(&mut std::io::stdout(), res)?;
167    } else {
168        let reused = if res.reused { " (reused)" } else { "" };
169        println!("Started {}{}", res.name, reused);
170        println!("  kind:     {}", res.kind.as_str());
171        println!("  pid:      {}", res.pid);
172        println!("  engine:   {:?}", res.engine);
173        println!("  endpoint: {}", res.endpoint);
174        println!("  profile:  {}", res.profile.display());
175    }
176    Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    // Stands in for a "browser" that's actually running: we only care that a
184    // live process's exe path matches `Installed.executable`, not that it's
185    // a real browser. Mirrors the technique in detect::mod::tests.
186    fn spawn_stand_in_process() -> (std::process::Child, PathBuf) {
187        let exe = PathBuf::from(if cfg!(windows) {
188            r"C:\Windows\System32\timeout.exe"
189        } else {
190            "/bin/sleep"
191        });
192        assert!(exe.exists(), "test requires {exe:?} to exist");
193        let child = std::process::Command::new(&exe)
194            .arg("5")
195            .spawn()
196            .expect("spawn stand-in process");
197        (child, exe)
198    }
199
200    fn installed(kind: Kind, executable: PathBuf) -> Installed {
201        Installed {
202            kind,
203            executable,
204            version: "unknown".to_string(),
205            engine: kind.engine(),
206        }
207    }
208
209    #[test]
210    fn resolve_kind_when_unspecified_prefers_running_kind_over_hardcoded_order() {
211        let (mut child, exe) = spawn_stand_in_process();
212
213        // Firefox is running (matches the live process's exe) but is listed
214        // after Chrome, and `first_chromium_or_first` would otherwise pick
215        // Chrome first. The running browser must win regardless of order.
216        let candidates = vec![
217            installed(Kind::Chrome, PathBuf::from("/definitely/not/a/real/chrome")),
218            installed(Kind::Firefox, exe),
219        ];
220
221        let resolved = resolve_kind_when_unspecified(&candidates);
222
223        let _ = child.kill();
224        let _ = child.wait();
225
226        assert_eq!(resolved, Some(Kind::Firefox));
227    }
228
229    #[test]
230    fn resolve_kind_when_unspecified_falls_back_when_nothing_running() {
231        let candidates = vec![
232            installed(
233                Kind::Firefox,
234                PathBuf::from("/definitely/not/a/real/firefox"),
235            ),
236            installed(Kind::Chrome, PathBuf::from("/definitely/not/a/real/chrome")),
237        ];
238        assert_eq!(
239            resolve_kind_when_unspecified(&candidates),
240            first_chromium_or_first(&candidates)
241        );
242        assert_eq!(
243            resolve_kind_when_unspecified(&candidates),
244            Some(Kind::Chrome)
245        );
246    }
247
248    #[test]
249    fn resolve_kind_when_unspecified_none_when_nothing_installed() {
250        assert_eq!(resolve_kind_when_unspecified(&[]), None);
251    }
252}