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(®istry, &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 let result = with_named_tab_recovery(&backend, &route.registry, &browser_name, name, op).await;
146 backend.shutdown().await;
147 result
148}