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
17pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub enum RuntimeSelection {
57 #[default]
59 Chrome,
60 Alloy,
62}
63
64impl RuntimeSelection {
65 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#[derive(Debug, Clone)]
111pub struct ChromiumProcessOptions {
112 pub runtime: RuntimeSelection,
117 pub executable_path: PathBuf,
119 pub user_data_dir: Option<String>,
126 pub enable_logging: Option<String>,
130 pub log_file: Option<String>,
133 pub v: Option<i32>,
136 pub vmodule: Option<String>,
139 pub unsafe_enable_startup_default_window: bool,
147 pub extra_args: Vec<String>,
149}
150
151#[derive(Debug, Clone)]
153pub struct StartChromiumOptions {
154 pub process: ChromiumProcessOptions,
156 pub backend: ChromiumBackendOptions,
158}
159
160#[derive(Debug)]
164pub struct ChromiumProcess {
165 child: Child,
166}
167
168impl ChromiumProcess {
169 pub fn kill(&mut self) -> std::io::Result<()> {
171 self.child.kill()
172 }
173
174 pub fn wait_blocking(&mut self) -> std::io::Result<ExitStatus> {
176 block_on(self.child.status())
177 }
178
179 pub fn try_wait(&mut self) -> std::io::Result<Option<ExitStatus>> {
181 self.child.try_status()
182 }
183
184 pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
186 self.child.status().await
187 }
188}
189
190pub 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 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 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 #[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 IpcClient::pass_child_pid(child.id());
298
299 #[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 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 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}