Skip to main content

browser_control/cli/
list.rs

1//! `list-installed` and `list-running` subcommand handlers.
2
3use anyhow::Result;
4use serde::Serialize;
5use std::time::Duration;
6
7use crate::cli::output::{print_json, print_table};
8use crate::detect::{self, Engine};
9use crate::registry::{BrowserRow, Registry};
10
11/// JSON-only enriched view of a registry row. Adds `cdp_port`, `cdp_ws_url`,
12/// `bidi_ws_url` where applicable. All added fields are optional.
13#[derive(Debug, Serialize)]
14struct EnrichedBrowserRow {
15    #[serde(flatten)]
16    row: BrowserRow,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    cdp_port: Option<u16>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    cdp_ws_url: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    bidi_ws_url: Option<String>,
23}
24
25/// Fetch `webSocketDebuggerUrl` from `<endpoint>/json/version` with a short timeout.
26/// Returns `None` if anything fails — callers should treat the probe as optional.
27async fn probe_ws_url(client: &reqwest::Client, endpoint: &str) -> Option<String> {
28    let base = endpoint.trim_end_matches('/');
29    let url = format!("{base}/json/version");
30    let resp = client.get(&url).send().await.ok()?;
31    if !resp.status().is_success() {
32        return None;
33    }
34    let v: serde_json::Value = resp.json().await.ok()?;
35    v.get("webSocketDebuggerUrl")
36        .and_then(|x| x.as_str())
37        .map(|s| s.to_string())
38}
39
40/// Enrich rows in parallel by probing each endpoint's `/json/version`.
41async fn enrich_rows(rows: Vec<BrowserRow>) -> Vec<EnrichedBrowserRow> {
42    let client = match reqwest::Client::builder()
43        .timeout(Duration::from_secs(2))
44        .build()
45    {
46        Ok(c) => c,
47        Err(_) => {
48            return rows
49                .into_iter()
50                .map(|row| {
51                    let cdp_port = match row.engine {
52                        Engine::Cdp => Some(row.port),
53                        _ => None,
54                    };
55                    EnrichedBrowserRow {
56                        row,
57                        cdp_port,
58                        cdp_ws_url: None,
59                        bidi_ws_url: None,
60                    }
61                })
62                .collect();
63        }
64    };
65
66    let probes = rows.into_iter().map(|row| {
67        let client = client.clone();
68        async move {
69            let ws = probe_ws_url(&client, &row.endpoint).await;
70            let (cdp_port, cdp_ws_url, bidi_ws_url) = match row.engine {
71                Engine::Cdp => (Some(row.port), ws, None),
72                Engine::Bidi => (None, None, ws),
73            };
74            EnrichedBrowserRow {
75                row,
76                cdp_port,
77                cdp_ws_url,
78                bidi_ws_url,
79            }
80        }
81    });
82    futures_util::future::join_all(probes).await
83}
84
85/// Implements `browser-control list-installed [--json]`.
86pub fn run_list_installed(json: bool) -> Result<()> {
87    let installed = detect::list_installed();
88    if json {
89        print_json(&mut std::io::stdout(), &installed)?;
90    } else {
91        let headers = ["KIND", "VERSION", "ENGINE", "EXECUTABLE"];
92        let rows = installed
93            .iter()
94            .map(|i| {
95                vec![
96                    i.kind.as_str().to_string(),
97                    i.version.clone(),
98                    format!("{:?}", i.engine).to_lowercase(),
99                    i.executable.display().to_string(),
100                ]
101            })
102            .collect::<Vec<_>>();
103        print_table(&mut std::io::stdout(), &headers, &rows)?;
104    }
105    Ok(())
106}
107
108/// Implements `browser-control list-running [--json]`.
109pub async fn run_list_running(json: bool) -> Result<()> {
110    let reg = Registry::open()?;
111    let alive = reg.list_alive()?;
112    if json {
113        let enriched = enrich_rows(alive).await;
114        print_json(&mut std::io::stdout(), &enriched)?;
115    } else {
116        let headers = [
117            "NAME", "KIND", "PID", "ENGINE", "ENDPOINT", "PROFILE", "STARTED",
118        ];
119        let rows = alive
120            .iter()
121            .map(|r| {
122                vec![
123                    r.name.clone(),
124                    r.kind.as_str().to_string(),
125                    r.pid.to_string(),
126                    format!("{:?}", r.engine).to_lowercase(),
127                    r.endpoint.clone(),
128                    r.profile_dir.display().to_string(),
129                    r.started_at.clone(),
130                ]
131            })
132            .collect::<Vec<_>>();
133        print_table(&mut std::io::stdout(), &headers, &rows)?;
134    }
135    Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::detect::Kind;
142    use std::path::PathBuf;
143
144    fn row_for(endpoint: &str, port: u16, engine: Engine) -> BrowserRow {
145        BrowserRow {
146            name: "test-name".to_string(),
147            kind: Kind::Chrome,
148            engine,
149            pid: 1,
150            endpoint: endpoint.to_string(),
151            port,
152            profile_dir: PathBuf::from("/tmp/profile"),
153            executable: PathBuf::from("/usr/bin/chrome"),
154            headless: false,
155            started_at: "2024-01-01T00:00:00Z".to_string(),
156        }
157    }
158
159    #[tokio::test]
160    async fn enriches_cdp_row_with_port_and_ws_url() {
161        let mut server = mockito::Server::new_async().await;
162        let m = server
163            .mock("GET", "/json/version")
164            .with_status(200)
165            .with_header("content-type", "application/json")
166            .with_body(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}"#)
167            .create_async()
168            .await;
169
170        let url = server.url();
171        let port: u16 = url::Url::parse(&url).unwrap().port().unwrap();
172        let row = row_for(&url, port, Engine::Cdp);
173
174        let enriched = enrich_rows(vec![row]).await;
175        m.assert_async().await;
176        assert_eq!(enriched.len(), 1);
177        let e = &enriched[0];
178        assert_eq!(e.cdp_port, Some(port));
179        assert_eq!(
180            e.cdp_ws_url.as_deref(),
181            Some("ws://127.0.0.1:1234/devtools/browser/abc")
182        );
183        assert!(e.bidi_ws_url.is_none());
184
185        let mut buf: Vec<u8> = Vec::new();
186        print_json(&mut buf, &enriched).unwrap();
187        let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
188        assert_eq!(v[0]["cdp_port"], port);
189        assert_eq!(
190            v[0]["cdp_ws_url"],
191            "ws://127.0.0.1:1234/devtools/browser/abc"
192        );
193        assert_eq!(v[0]["name"], "test-name");
194        assert_eq!(v[0]["engine"], "cdp");
195        assert_eq!(v[0]["endpoint"], url);
196        assert!(v[0].get("bidi_ws_url").is_none());
197    }
198
199    #[tokio::test]
200    async fn cdp_port_present_even_when_probe_fails() {
201        let mut server = mockito::Server::new_async().await;
202        let _m = server
203            .mock("GET", "/json/version")
204            .with_status(500)
205            .create_async()
206            .await;
207
208        let url = server.url();
209        let port: u16 = url::Url::parse(&url).unwrap().port().unwrap();
210        let row = row_for(&url, port, Engine::Cdp);
211
212        let enriched = enrich_rows(vec![row]).await;
213        assert_eq!(enriched.len(), 1);
214        let e = &enriched[0];
215        assert_eq!(e.cdp_port, Some(port));
216        assert!(e.cdp_ws_url.is_none());
217
218        let mut buf: Vec<u8> = Vec::new();
219        print_json(&mut buf, &enriched).unwrap();
220        let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
221        assert_eq!(v[0]["cdp_port"], port);
222        assert!(v[0].get("cdp_ws_url").is_none());
223    }
224
225    #[tokio::test]
226    async fn enriches_bidi_row_with_ws_url_only() {
227        let mut server = mockito::Server::new_async().await;
228        let _m = server
229            .mock("GET", "/json/version")
230            .with_status(200)
231            .with_header("content-type", "application/json")
232            .with_body(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:5555/session"}"#)
233            .create_async()
234            .await;
235
236        let url = server.url();
237        let port: u16 = url::Url::parse(&url).unwrap().port().unwrap();
238        let row = row_for(&url, port, Engine::Bidi);
239
240        let enriched = enrich_rows(vec![row]).await;
241        let e = &enriched[0];
242        assert!(e.cdp_port.is_none());
243        assert!(e.cdp_ws_url.is_none());
244        assert_eq!(
245            e.bidi_ws_url.as_deref(),
246            Some("ws://127.0.0.1:5555/session")
247        );
248
249        let mut buf: Vec<u8> = Vec::new();
250        print_json(&mut buf, &enriched).unwrap();
251        let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
252        assert!(v[0].get("cdp_port").is_none());
253        assert_eq!(v[0]["bidi_ws_url"], "ws://127.0.0.1:5555/session");
254    }
255}