Skip to main content

cbf_chrome/
process.rs

1use async_process::{Child, Command};
2use cbf_chrome_sys::ffi::{cbf_bridge_client_create, cbf_bridge_client_destroy};
3use futures_lite::future::block_on;
4use std::{env, path::PathBuf, process::ExitStatus};
5
6use cbf::{
7    browser::{BrowserSession, EventStream},
8    delegate::BackendDelegate,
9    error::{ApiErrorKind, BackendErrorInfo, Error},
10};
11
12use crate::{
13    backend::{ChromiumBackend, ChromiumBackendOptions},
14    ffi::IpcClient,
15};
16
17/// Resolves Chromium executable path for CBF applications.
18///
19/// Resolution order:
20/// 1. Explicit path (for example CLI argument)
21/// 2. `CBF_CHROMIUM_EXECUTABLE` environment variable
22/// 3. Path relative to current app bundle:
23///    `../Frameworks/Chromium.app/Contents/MacOS/Chromium`
24///
25/// Returns `None` when no candidate can be resolved.
26pub fn resolve_chromium_executable(explicit_path: Option<PathBuf>) -> Option<PathBuf> {
27    explicit_path
28        .or_else(|| env::var_os("CBF_CHROMIUM_EXECUTABLE").map(PathBuf::from))
29        .or_else(resolve_chromium_executable_from_bundle)
30}
31
32fn resolve_chromium_executable_from_bundle() -> Option<PathBuf> {
33    let current_exe = env::current_exe().ok()?;
34    let macos_dir = current_exe.parent()?;
35    let contents_dir = macos_dir.parent()?;
36
37    if contents_dir.file_name()?.to_str()? != "Contents" {
38        return None;
39    }
40
41    let candidate = contents_dir
42        .join("Frameworks")
43        .join("Chromium.app")
44        .join("Contents")
45        .join("MacOS")
46        .join("Chromium");
47
48    candidate.is_file().then_some(candidate)
49}
50
51/// Runtime selection for Chromium-backed startup.
52///
53/// `Chrome` is the only currently supported runtime. `Alloy` is reserved as an
54/// explicit future selection target, but remains unavailable in this phase.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub enum RuntimeSelection {
57    /// Chrome-backed runtime path used by current CBF integration.
58    #[default]
59    Chrome,
60    /// Reserved for future Alloy runtime work. Selecting this currently fails.
61    Alloy,
62}
63
64impl RuntimeSelection {
65    /// Stable string form for config surfaces and diagnostics.
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Chrome => "chrome",
69            Self::Alloy => "alloy",
70        }
71    }
72}
73
74impl std::fmt::Display for RuntimeSelection {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80impl std::str::FromStr for RuntimeSelection {
81    type Err = String;
82
83    fn from_str(value: &str) -> Result<Self, Self::Err> {
84        match value {
85            "chrome" => Ok(Self::Chrome),
86            "alloy" => Ok(Self::Alloy),
87            _ => Err(format!(
88                "unsupported runtime '{value}': expected 'chrome' or 'alloy'"
89            )),
90        }
91    }
92}
93
94fn validate_runtime_selection(runtime: RuntimeSelection) -> Result<(), Error> {
95    if matches!(runtime, RuntimeSelection::Chrome) {
96        return Ok(());
97    }
98
99    Err(Error::BackendFailure(BackendErrorInfo {
100        kind: ApiErrorKind::Unsupported,
101        operation: None,
102        detail: Some(format!(
103            "runtime '{}' is not available in this phase; use 'chrome'",
104            runtime
105        )),
106    }))
107}
108
109/// Options for launching the Chromium process.
110#[derive(Debug, Clone)]
111pub struct ChromiumProcessOptions {
112    /// Runtime path to use for startup.
113    ///
114    /// The default is `chrome`. `alloy` is currently reserved and will be
115    /// rejected by `start_chromium` until that runtime exists.
116    pub runtime: RuntimeSelection,
117    /// Path to the browser executable (e.g. "Chromium.app/Contents/MacOS/Chromium").
118    pub executable_path: PathBuf,
119    /// Path to the user data directory.
120    /// If provided, passed as `--user-data-dir=<path>`.
121    /// Prefer setting this explicitly unless you have a strong reason not to.
122    /// If `None`, Chromium may use a default profile location, which can conflict
123    /// with normal Chromium usage and risk profile data issues (for example,
124    /// profile/schema version mismatch).
125    pub user_data_dir: Option<String>,
126    /// Whether to enable logging.
127    /// If provided, passed as `--enable-logging=<stream>`.
128    /// e.g. "--enable-logging=stderr"
129    pub enable_logging: Option<String>,
130    /// Path to the log file.
131    /// If provided, passed as `--log-file=<path>`.
132    pub log_file: Option<String>,
133    /// Chromium VLOG verbosity.
134    /// If provided, passed as `--v=<level>`.
135    pub v: Option<i32>,
136    /// Per-module VLOG verbosity.
137    /// If provided, passed as `--vmodule=<pattern1=N,...>`.
138    pub vmodule: Option<String>,
139    /// Allow Chromium to create its default startup window.
140    ///
141    /// By default, CBF passes `--no-startup-window` to prevent Chromium's
142    /// built-in initial window from being created unexpectedly.
143    ///
144    /// This option is intentionally marked unsafe because enabling it can
145    /// interfere with CBF-controlled window lifecycle behavior.
146    pub unsafe_enable_startup_default_window: bool,
147    /// Additional arguments to pass to the browser process.
148    pub extra_args: Vec<String>,
149}
150
151/// Combined options for launching Chromium and connecting the backend.
152#[derive(Debug, Clone)]
153pub struct StartChromiumOptions {
154    /// Options for the Chromium child process.
155    pub process: ChromiumProcessOptions,
156    /// Options for backend IPC connection behavior.
157    pub backend: ChromiumBackendOptions,
158}
159
160/// A handle to the running Chromium process.
161///
162/// This struct holds the `std::process::Child` and allows managing its lifecycle.
163#[derive(Debug)]
164pub struct ChromiumProcess {
165    child: Child,
166}
167
168impl ChromiumProcess {
169    /// Forcefully kills the browser process.
170    pub fn kill(&mut self) -> std::io::Result<()> {
171        self.child.kill()
172    }
173
174    /// Waits for the browser process to exit.
175    pub fn wait_blocking(&mut self) -> std::io::Result<ExitStatus> {
176        block_on(self.child.status())
177    }
178
179    /// Attempts to check if the browser process has exited without blocking.
180    pub fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
181        self.child.try_status()
182    }
183
184    /// Await the browser process exit status asynchronously.
185    pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
186        self.child.status().await
187    }
188}
189
190/// Launches the Chromium process and connects to it via an inherited Mojo endpoint.
191///
192/// This function prepares the IPC channel, spawns the browser process with the
193/// channel handle argument, completes the Mojo connection, and authenticates with
194/// a freshly generated per-session token before returning a ready backend session.
195pub fn start_chromium(
196    options: StartChromiumOptions,
197    delegate: impl BackendDelegate,
198) -> Result<
199    (
200        BrowserSession<ChromiumBackend>,
201        EventStream<ChromiumBackend>,
202        ChromiumProcess,
203    ),
204    Error,
205> {
206    let StartChromiumOptions { process, backend } = options;
207
208    let ChromiumProcessOptions {
209        runtime,
210        executable_path,
211        user_data_dir,
212        enable_logging,
213        log_file,
214        v,
215        vmodule,
216        unsafe_enable_startup_default_window,
217        extra_args,
218    } = process;
219
220    validate_runtime_selection(runtime)?;
221
222    // Create the bridge client handle and prepare the Mojo channel pair.
223    let inner = unsafe { cbf_bridge_client_create() };
224    if inner.is_null() {
225        return Err(Error::BackendFailure(cbf::error::BackendErrorInfo {
226            kind: cbf::error::ApiErrorKind::ConnectTimeout,
227            operation: None,
228            detail: Some("cbf_bridge_client_create returned null".to_owned()),
229        }));
230    }
231
232    let (remote_fd, switch_arg) = IpcClient::prepare_channel().map_err(|_| {
233        unsafe { cbf_bridge_client_destroy(inner) };
234        Error::BackendFailure(cbf::error::BackendErrorInfo {
235            kind: cbf::error::ApiErrorKind::ConnectTimeout,
236            operation: None,
237            detail: Some("prepare_channel failed".to_owned()),
238        })
239    })?;
240
241    // Generate a per-session token.
242    let mut token_bytes = [0u8; 32];
243    getrandom::fill(&mut token_bytes).map_err(|_| {
244        Error::BackendFailure(cbf::error::BackendErrorInfo {
245            kind: cbf::error::ApiErrorKind::ConnectTimeout,
246            operation: None,
247            detail: Some("token generation failed".to_owned()),
248        })
249    })?;
250    let session_token: String = token_bytes.iter().map(|b| format!("{b:02x}")).collect();
251
252    let mut command = Command::new(&executable_path);
253
254    command.arg("--enable-features=Cbf");
255    command.arg(&switch_arg);
256    command.arg(format!("--cbf-session-token={session_token}"));
257
258    // Clear FD_CLOEXEC on the remote endpoint fd so it is inherited by the child.
259    #[cfg(unix)]
260    {
261        use std::os::unix::io::RawFd;
262        if remote_fd >= 0 {
263            unsafe { libc::fcntl(remote_fd as RawFd, libc::F_SETFD, 0) };
264        }
265    }
266
267    if let Some(user_data_dir) = &user_data_dir {
268        command.arg(format!("--user-data-dir={}", user_data_dir));
269    }
270
271    if let Some(enable_logging) = enable_logging {
272        command.arg(format!("--enable-logging={}", enable_logging));
273    }
274
275    if let Some(log_file) = &log_file {
276        command.arg(format!("--log-file={}", log_file));
277    }
278
279    if let Some(v) = v {
280        command.arg(format!("--v={}", v));
281    }
282
283    if let Some(vmodule) = &vmodule {
284        command.arg(format!("--vmodule={}", vmodule));
285    }
286
287    if !unsafe_enable_startup_default_window {
288        command.arg("--no-startup-window");
289    }
290
291    command.args(&extra_args);
292
293    let child = command.spawn().map_err(Error::ProcessSpawnError)?;
294
295    // Notify the bridge of the child PID: on macOS this registers the Mach
296    // port with the rendezvous server; on other platforms it is bookkeeping.
297    IpcClient::pass_child_pid(child.id());
298
299    // Close the parent's copy of the remote fd after spawning.
300    #[cfg(unix)]
301    {
302        use std::os::unix::io::RawFd;
303        if remote_fd >= 0 {
304            unsafe { libc::close(remote_fd as RawFd) };
305        }
306    }
307
308    // Complete the Mojo handshake: send the OutgoingInvitation and bind the remote.
309    let client = unsafe { IpcClient::connect_inherited(inner) }.map_err(|_| {
310        Error::BackendFailure(cbf::error::BackendErrorInfo {
311            kind: cbf::error::ApiErrorKind::ConnectTimeout,
312            operation: None,
313            detail: Some("connect_inherited failed".to_owned()),
314        })
315    })?;
316
317    // Authenticate and set up the browser observer.
318    client.authenticate(&session_token).map_err(|_| {
319        Error::BackendFailure(cbf::error::BackendErrorInfo {
320            kind: cbf::error::ApiErrorKind::ConnectTimeout,
321            operation: None,
322            detail: Some("authenticate failed".to_owned()),
323        })
324    })?;
325
326    let backend = ChromiumBackend::new(backend, client);
327    let (session, events) = BrowserSession::connect(backend, delegate, None)?;
328
329    Ok((session, events, ChromiumProcess { child }))
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn runtime_selection_defaults_to_chrome() {
338        assert_eq!(RuntimeSelection::default(), RuntimeSelection::Chrome);
339        assert_eq!(RuntimeSelection::default().to_string(), "chrome");
340    }
341
342    #[test]
343    fn runtime_selection_rejects_alloy_until_implemented() {
344        let err = validate_runtime_selection(RuntimeSelection::Alloy).unwrap_err();
345
346        match err {
347            Error::BackendFailure(info) => {
348                assert_eq!(info.kind, ApiErrorKind::Unsupported);
349                assert_eq!(
350                    info.detail.as_deref(),
351                    Some("runtime 'alloy' is not available in this phase; use 'chrome'")
352                );
353            }
354            other => panic!("unexpected error: {other:?}"),
355        }
356    }
357
358    #[test]
359    fn runtime_selection_parses_known_values() {
360        assert_eq!("chrome".parse(), Ok(RuntimeSelection::Chrome));
361        assert_eq!("alloy".parse(), Ok(RuntimeSelection::Alloy));
362    }
363}