Skip to main content

browser_control/cli/
route.rs

1//! Shared routing preamble + named-tab dispatch for the page-evaluating CLI
2//! commands (`eval`, `fetch`, `storage`).
3//!
4//! These commands all accept the unified `<browser>[/<tab>]` positional plus a
5//! mutually-exclusive `--target <regex>` and fan out into the *same three
6//! routing paths*, picked by `(tab_name, target)`:
7//!
8//! 1. **named-tab** (`<browser>/<tab>`, no `--target`) — resolve `<tab>` in the
9//!    `tabs` table via the engine-agnostic [`TabBackend`] and run the op under
10//!    [`with_named_tab_recovery`], so a tab that dies between resolve and op is
11//!    recovered once. Requires a registered browser. See [`run_named_tab`].
12//! 2. **bare browser** (no `--target`) — the per-command default. This arm
13//!    *differs per command* (scratch tab for `eval`/`storage`, origin-bound
14//!    attach for `fetch`, plus an external-endpoint fallback), so it stays in
15//!    each command rather than here.
16//! 3. **target-regex** (`--target <regex>`) — legacy [`PageSession::attach`]
17//!    against a user tab matching the URL regex. Also per-command (it differs
18//!    in await-promise flags / timeouts), so it stays in each command.
19//!
20//! The genuinely shared pieces — the preamble (parse, mutual-exclusion check,
21//! browser resolution, registry handle, BiDi lock) and the named-tab arm — live
22//! here so the three commands cannot drift on them. The per-command variation
23//! (the JS expression, the await-promise flag, the timeout, and the
24//! bare-browser arm) is supplied by the caller.
25//!
26//! [`PageSession::attach`]: crate::session::PageSession::attach
27//! [`TabBackend`]: crate::session::backend::TabBackend
28
29use std::future::Future;
30
31use anyhow::{bail, Result};
32
33use crate::cli::env_resolver::{self, ResolvedBrowser, Source};
34use crate::cli::mcp::{acquire_bidi_lock_if_needed, resolve_browser};
35use crate::cli::routing::strip_tab;
36use crate::cli::trace::CommandTrace;
37use crate::registry::{BidiLockGuard, Registry};
38use crate::session::backend::{open_backend, TabBackend};
39use crate::session::with_named_tab_recovery;
40
41/// Outcome of the shared routing preamble. Holds everything the per-command
42/// dispatch needs: the resolved browser, the open [`Registry`] handle, the held
43/// BiDi lock guard (RAII — released when this struct drops), and the parsed
44/// `(tab_name, browser_only)` from the positional.
45///
46/// The `_bidi_lock` field is never read directly; it exists to keep the lock
47/// held for the lifetime of the route and releases after the command finishes
48/// its dispatch.
49pub struct Route {
50    /// The resolved browser endpoint + engine + source.
51    pub resolved: ResolvedBrowser,
52    /// Single Registry handle for the route lifetime: the BiDi lock, scratch
53    /// row, and named-tab resolution all share it. Re-opening would block on
54    /// the per-process file lock.
55    pub registry: Registry,
56    /// The `<tab>` parsed out of `<browser>/<tab>`, if any.
57    pub tab_name: Option<String>,
58    /// The positional with any `/<tab>` suffix stripped (the bare browser).
59    pub browser_only: String,
60    /// Held BiDi single-session lock; RAII-released on drop. `None` for CDP
61    /// engines and external URL endpoints.
62    _bidi_lock: Option<BidiLockGuard>,
63}
64
65/// Run the shared preamble: parse the `<browser>[/<tab>]` positional, enforce
66/// the `<tab>` / `--target` mutual exclusion (in ONE place with ONE message),
67/// resolve the browser, record `trace.browser`/`trace.engine`, open the
68/// registry, and acquire the BiDi lock if applicable.
69///
70/// `browser` is the raw positional/env value; `target` is `--target`. Returns a
71/// [`Route`] the caller dispatches on via `(route.tab_name, target)`.
72pub async fn preamble(
73    browser: Option<String>,
74    target: Option<&str>,
75    trace: &mut CommandTrace,
76) -> Result<Route> {
77    let raw = browser.unwrap_or_default();
78    let parsed = if raw.is_empty() {
79        None
80    } else {
81        Some(env_resolver::parse_target(&raw)?)
82    };
83    let tab_name = parsed.as_ref().and_then(|p| p.tab.clone());
84    if tab_name.is_some() && target.is_some() {
85        bail!("specify the tab via either `<browser>/<name>` or `--target <regex>`, not both");
86    }
87    let browser_only = parsed
88        .as_ref()
89        .map(|p| strip_tab(&raw, p.tab.as_deref()))
90        .unwrap_or_default();
91    let resolved = resolve_browser(if browser_only.is_empty() {
92        None
93    } else {
94        Some(browser_only.clone())
95    })
96    .await?;
97    trace.browser(&browser_only).engine(resolved.engine);
98
99    // Open the registry once for the route lifetime — used for the BiDi
100    // single-session lock, scratch row, and named-tab resolution. Re-opening
101    // would block on the per-process file lock.
102    let registry = Registry::open()?;
103    // Acquire the Firefox BiDi lock if applicable. RAII releases on drop; held
104    // across whatever path the caller takes.
105    let bidi_lock = acquire_bidi_lock_if_needed(&registry, &resolved)?;
106
107    Ok(Route {
108        resolved,
109        registry,
110        tab_name,
111        browser_only,
112        _bidi_lock: bidi_lock,
113    })
114}
115
116/// The named-tab routing arm, identical across `eval`/`fetch`/`storage`:
117/// resolve the registered browser name, open its [`TabBackend`], and run the
118/// caller's `op` against the named tab under [`with_named_tab_recovery`] (which
119/// recovers once if the tab dies between resolve and op).
120///
121/// `name` is the tab name; `op` evaluates the command-specific JS — the JS
122/// expression, await-promise flag, and timeout all live inside the closure, so
123/// they are the per-command variation point. `no_external_msg` is the `bail!`
124/// text used when the source is not a registered browser; it is passed in
125/// because the existing commands phrase it slightly differently and this is a
126/// behavior-preserving refactor.
127///
128/// The caller is responsible for `trace.route("named-tab")` /
129/// `trace.tab_name(...)` so the trace contract stays visible at the call site.
130pub async fn run_named_tab<T, F, Fut>(
131    route: &Route,
132    name: &str,
133    no_external_msg: &str,
134    op: F,
135) -> Result<T>
136where
137    F: FnMut(TabBackend, String) -> Fut,
138    Fut: Future<Output = Result<T>>,
139{
140    let browser_name = match &route.resolved.source {
141        Source::Registered { name } => name.clone(),
142        _ => bail!("{no_external_msg}"),
143    };
144    let backend = open_backend(&route.resolved.endpoint, route.resolved.engine).await?;
145    with_named_tab_recovery(&backend, &route.registry, &browser_name, name, op).await
146}