From 3f0749c527eb1ffd70a05cc15fc512e0b6a776a6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:11:39 +0000 Subject: [PATCH 01/11] refactor: split crate into lib and bin Expose the existing modules through src/lib.rs so the crate can be consumed as a library; src/main.rs keeps the CLI entrypoint and now imports those modules from the lib. Co-Authored-By: glavin@coframe.com --- cli/src/lib.rs | 17 +++++++++++++++++ cli/src/main.rs | 27 +++++++-------------------- 2 files changed, 24 insertions(+), 20 deletions(-) create mode 100644 cli/src/lib.rs diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 000000000..6f9c01c9a --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,17 @@ +pub mod chat; +pub mod color; +pub mod commands; +pub mod connection; +pub mod doctor; +pub mod flags; +pub mod install; +pub mod mcp; +pub mod native; +pub mod output; +pub mod plugins; +pub mod read; +pub mod skills; +#[cfg(test)] +pub mod test_utils; +pub mod upgrade; +pub mod validation; diff --git a/cli/src/main.rs b/cli/src/main.rs index 304ca570b..348b75e80 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,20 +1,7 @@ -mod chat; -mod color; -mod commands; -mod connection; -mod doctor; -mod flags; -mod install; -mod mcp; -mod native; -mod output; -mod plugins; -mod read; -mod skills; -#[cfg(test)] -mod test_utils; -mod upgrade; -mod validation; +use agent_browser::{ + chat, color, commands, connection, doctor, flags, install, mcp, native, output, plugins, read, + skills, upgrade, validation, +}; use serde_json::json; use sha2::{Digest, Sha256}; @@ -393,7 +380,7 @@ fn parse_proxy(proxy_str: &str) -> ParsedProxy { } fn run_profiles(json_mode: bool) { - use crate::native::cdp::chrome::{find_chrome_user_data_dir, list_chrome_profiles}; + use native::cdp::chrome::{find_chrome_user_data_dir, list_chrome_profiles}; let user_data_dir = match find_chrome_user_data_dir() { Some(dir) => dir, @@ -1987,11 +1974,11 @@ mod tests { #[test] fn test_attach_plugins_to_command_adds_registry_payload() { - let plugins = vec![crate::plugins::PluginConfig { + let plugins = vec![plugins::PluginConfig { name: "stealth".to_string(), command: "agent-browser-plugin-stealth".to_string(), capabilities: vec!["launch.mutate".to_string()], - ..crate::plugins::PluginConfig::default() + ..plugins::PluginConfig::default() }]; let mut cmd = json!({ "action": "navigate", "url": "https://example.com" }); From 878e421b3bf5b47468e9903f2db93a7c1a123e4b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:53:36 +0000 Subject: [PATCH 02/11] feat: add rt module abstracting task spawning and timers over tokio/wasm Co-Authored-By: glavin@coframe.com --- cli/src/lib.rs | 6 + cli/src/main.rs | 2 +- cli/src/rt.rs | 289 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 cli/src/rt.rs diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 6f9c01c9a..ed70584c0 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,17 +1,23 @@ +#[cfg(not(target_arch = "wasm32"))] pub mod chat; pub mod color; pub mod commands; pub mod connection; +#[cfg(not(target_arch = "wasm32"))] pub mod doctor; pub mod flags; pub mod install; +#[cfg(not(target_arch = "wasm32"))] pub mod mcp; pub mod native; pub mod output; pub mod plugins; pub mod read; +pub mod rt; +#[cfg(not(target_arch = "wasm32"))] pub mod skills; #[cfg(test)] pub mod test_utils; +#[cfg(not(target_arch = "wasm32"))] pub mod upgrade; pub mod validation; diff --git a/cli/src/main.rs b/cli/src/main.rs index 348b75e80..c98ce0cd8 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,5 +1,5 @@ use agent_browser::{ - chat, color, commands, connection, doctor, flags, install, mcp, native, output, plugins, read, + chat, color, commands, connection, doctor, flags, install, mcp, native, output, plugins, skills, upgrade, validation, }; diff --git a/cli/src/rt.rs b/cli/src/rt.rs new file mode 100644 index 000000000..d3c57298a --- /dev/null +++ b/cli/src/rt.rs @@ -0,0 +1,289 @@ +//! Runtime abstraction over task spawning and timers. +//! +//! Native targets delegate to tokio. The wasm32 implementation drives the +//! same APIs from the JavaScript event loop so the daemon core can run in +//! environments like Cloudflare Workers. + +pub use std::time::Duration; + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + pub use std::time::Instant; + pub use tokio::task::{spawn_blocking, JoinHandle}; + pub use tokio::time::{interval, sleep, timeout, MissedTickBehavior}; + + pub async fn sleep_until(deadline: Instant) { + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + } + + pub async fn timeout_at( + deadline: Instant, + future: F, + ) -> Result { + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), future).await + } + + pub fn spawn(future: F) -> JoinHandle + where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, + { + tokio::spawn(future) + } +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use super::Duration; + use futures_util::future::{AbortHandle, Abortable}; + use std::future::Future; + use std::ops::{Add, AddAssign, Sub, SubAssign}; + + /// Monotonic-enough instant backed by `Date.now()`. + #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] + pub struct Instant { + millis: f64, + } + + impl Instant { + pub fn now() -> Self { + Instant { + millis: js_sys::Date::now(), + } + } + + pub fn elapsed(&self) -> Duration { + Instant::now().saturating_duration_since(*self) + } + + pub fn duration_since(&self, earlier: Instant) -> Duration { + self.saturating_duration_since(earlier) + } + + pub fn saturating_duration_since(&self, earlier: Instant) -> Duration { + let delta = self.millis - earlier.millis; + if delta <= 0.0 { + Duration::ZERO + } else { + Duration::from_secs_f64(delta / 1000.0) + } + } + + pub fn checked_add(&self, duration: Duration) -> Option { + Some(Instant { + millis: self.millis + duration.as_secs_f64() * 1000.0, + }) + } + + pub fn checked_sub(&self, duration: Duration) -> Option { + Some(Instant { + millis: self.millis - duration.as_secs_f64() * 1000.0, + }) + } + + pub fn checked_duration_since(&self, earlier: Instant) -> Option { + if self.millis >= earlier.millis { + Some(self.saturating_duration_since(earlier)) + } else { + None + } + } + } + + impl Add for Instant { + type Output = Instant; + fn add(self, rhs: Duration) -> Instant { + self.checked_add(rhs).unwrap() + } + } + + impl AddAssign for Instant { + fn add_assign(&mut self, rhs: Duration) { + *self = *self + rhs; + } + } + + impl Sub for Instant { + type Output = Instant; + fn sub(self, rhs: Duration) -> Instant { + self.checked_sub(rhs).unwrap() + } + } + + impl SubAssign for Instant { + fn sub_assign(&mut self, rhs: Duration) { + *self = *self - rhs; + } + } + + impl Sub for Instant { + type Output = Duration; + fn sub(self, rhs: Instant) -> Duration { + self.saturating_duration_since(rhs) + } + } + + pub async fn sleep(duration: Duration) { + let millis = duration.as_millis().min(i32::MAX as u128) as i32; + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + let global = js_sys::global(); + let set_timeout = js_sys::Reflect::get(&global, &"setTimeout".into()) + .expect("setTimeout not available"); + let set_timeout: js_sys::Function = set_timeout.into(); + set_timeout + .call2(&global, &resolve, &millis.into()) + .expect("setTimeout call failed"); + }); + let _ = wasm_bindgen_futures::JsFuture::from(promise).await; + } + + pub async fn sleep_until(deadline: Instant) { + let now = Instant::now(); + if deadline > now { + sleep(deadline.saturating_duration_since(now)).await; + } + } + + /// Error returned when a timeout elapses, mirroring `tokio::time::error::Elapsed`. + #[derive(Debug)] + pub struct Elapsed; + + impl std::fmt::Display for Elapsed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "deadline has elapsed") + } + } + + impl std::error::Error for Elapsed {} + + pub async fn timeout(duration: Duration, future: F) -> Result { + use futures_util::future::{select, Either}; + let sleep_fut = Box::pin(sleep(duration)); + let future = Box::pin(future); + match select(future, sleep_fut).await { + Either::Left((value, _)) => Ok(value), + Either::Right(((), _)) => Err(Elapsed), + } + } + + pub async fn timeout_at(deadline: Instant, future: F) -> Result { + let now = Instant::now(); + timeout(deadline.saturating_duration_since(now), future).await + } + + #[derive(Debug, Clone, Copy)] + pub enum MissedTickBehavior { + Burst, + Delay, + Skip, + } + + pub struct Interval { + period: Duration, + next: Instant, + } + + impl Interval { + pub fn set_missed_tick_behavior(&mut self, _behavior: MissedTickBehavior) {} + + pub async fn tick(&mut self) -> Instant { + sleep_until(self.next).await; + let tick = self.next; + self.next = Instant::now() + self.period; + tick + } + } + + pub fn interval(period: Duration) -> Interval { + Interval { + period, + next: Instant::now(), + } + } + + #[derive(Debug)] + pub struct JoinError { + cancelled: bool, + } + + impl JoinError { + pub fn is_cancelled(&self) -> bool { + self.cancelled + } + + pub fn is_panic(&self) -> bool { + !self.cancelled + } + } + + impl std::fmt::Display for JoinError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.cancelled { + write!(f, "task was cancelled") + } else { + write!(f, "task failed") + } + } + } + + impl std::error::Error for JoinError {} + + /// Handle to a task spawned on the JavaScript event loop. + pub struct JoinHandle { + receiver: tokio::sync::oneshot::Receiver, + abort_handle: AbortHandle, + } + + impl JoinHandle { + pub fn abort(&self) { + self.abort_handle.abort(); + } + + pub fn is_finished(&self) -> bool { + self.abort_handle.is_aborted() + } + } + + impl Future for JoinHandle { + type Output = Result; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + let this = self.get_mut(); + std::pin::Pin::new(&mut this.receiver) + .poll(cx) + .map(|result| result.map_err(|_| JoinError { cancelled: true })) + } + } + + pub fn spawn(future: F) -> JoinHandle + where + F: Future + 'static, + F::Output: 'static, + { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + let task = Abortable::new(future, abort_registration); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(output) = task.await { + let _ = sender.send(output); + } + }); + JoinHandle { + receiver, + abort_handle, + } + } + + pub fn spawn_blocking(f: F) -> JoinHandle + where + F: FnOnce() -> T + 'static, + T: 'static, + { + spawn(async move { f() }) + } +} + +pub use imp::*; From 376604ca2ce48a121a96818057a32f75207b8ecf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:53:36 +0000 Subject: [PATCH 03/11] feat: inject CDP transport behind CdpTransportSink so non-native hosts can supply their own WebSocket Co-Authored-By: glavin@coframe.com --- cli/src/native/browser.rs | 106 ++++++++-------- cli/src/native/cdp/client.rs | 236 +++++++++++++++++++++++++---------- 2 files changed, 221 insertions(+), 121 deletions(-) diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 2dbdb7d98..b88f8462c 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -302,6 +302,7 @@ pub enum WaitUntil { } impl WaitUntil { + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Self { match s { "domcontentloaded" => Self::DomContentLoaded, @@ -423,7 +424,7 @@ impl BrowserManager { (url, BrowserProcess::Lightpanda(lp)) } _ => { - let chrome = tokio::task::spawn_blocking(move || launch_chrome(&options)) + let chrome = crate::rt::spawn_blocking(move || launch_chrome(&options)) .await .map_err(|e| format!("Chrome launch task failed: {}", e))??; let url = chrome.ws_url.clone(); @@ -747,7 +748,7 @@ impl BrowserManager { /// `timeout_ms`. A discarded tab keeps its CDP session but has no /// renderer to reply (#1528). A CDP error still counts as responding. async fn renderer_responds(&self, session_id: &str, timeout_ms: u64) -> bool { - tokio::time::timeout( + crate::rt::timeout( Duration::from_millis(timeout_ms), self.client.send_command( "Runtime.evaluate", @@ -782,7 +783,7 @@ impl BrowserManager { if dialog_session == Some(session_id) { return Ok(RendererState::DialogBlocked); } - match tokio::time::timeout( + match crate::rt::timeout( Duration::from_millis(REVIVED_RENDERER_TIMEOUT_MS), self.client.send_command( "Target.activateTarget", @@ -944,9 +945,9 @@ impl BrowserManager { WaitUntil::None => return Ok(()), }; - let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms); + let timeout = crate::rt::Duration::from_millis(self.default_timeout_ms); - tokio::time::timeout(timeout, async { + crate::rt::timeout(timeout, async { loop { match rx.recv().await { Ok(event) => { @@ -971,7 +972,7 @@ impl BrowserManager { session_id: &str, rx: &mut broadcast::Receiver, ) -> Result<(), String> { - let timeout = tokio::time::Duration::from_millis(self.default_timeout_ms); + let timeout = crate::rt::Duration::from_millis(self.default_timeout_ms); poll_network_idle(session_id, rx, timeout).await } @@ -1085,7 +1086,7 @@ impl BrowserManager { if let Some(mut process) = self.browser_process.take() { let timeout = std::time::Duration::from_secs(5); - let _ = tokio::task::spawn_blocking(move || { + let _ = crate::rt::spawn_blocking(move || { process.wait_or_kill(timeout); }) .await; @@ -1105,8 +1106,8 @@ impl BrowserManager { /// Checks if the CDP connection is alive by sending a simple command. /// Returns false if the command times out or fails. pub async fn is_connection_alive(&self) -> bool { - let timeout = tokio::time::Duration::from_secs(3); - let result = tokio::time::timeout( + let timeout = crate::rt::Duration::from_secs(3); + let result = crate::rt::timeout( timeout, self.client .send_command_no_params("Browser.getVersion", None), @@ -1839,16 +1840,16 @@ impl BrowserManager { async fn poll_network_idle( session_id: &str, rx: &mut broadcast::Receiver, - overall_timeout: tokio::time::Duration, + overall_timeout: crate::rt::Duration, ) -> Result<(), String> { let pending = Arc::new(Mutex::new(HashSet::::new())); - tokio::time::timeout(overall_timeout, async { - let mut idle_start: Option = None; + crate::rt::timeout(overall_timeout, async { + let mut idle_start: Option = None; loop { let recv_result = - tokio::time::timeout(tokio::time::Duration::from_millis(600), rx.recv()).await; + crate::rt::timeout(crate::rt::Duration::from_millis(600), rx.recv()).await; match recv_result { Ok(Ok(event)) if event.session_id.as_deref() == Some(session_id) => { @@ -1866,12 +1867,12 @@ async fn poll_network_idle( { p.remove(id); if p.is_empty() { - idle_start = Some(tokio::time::Instant::now()); + idle_start = Some(crate::rt::Instant::now()); } } } "Page.loadEventFired" if p.is_empty() => { - idle_start = Some(tokio::time::Instant::now()); + idle_start = Some(crate::rt::Instant::now()); } _ => {} } @@ -1887,13 +1888,13 @@ async fn poll_network_idle( // has already loaded (e.g. cached pages). let p = pending.lock().await; if p.is_empty() && idle_start.is_none() { - idle_start = Some(tokio::time::Instant::now()); + idle_start = Some(crate::rt::Instant::now()); } } } if let Some(start) = idle_start { - if start.elapsed() >= tokio::time::Duration::from_millis(500) { + if start.elapsed() >= crate::rt::Duration::from_millis(500) { return Ok(()); } } @@ -1922,7 +1923,7 @@ async fn connect_cdp_with_retry( } } - tokio::time::sleep(poll_interval).await; + crate::rt::sleep(poll_interval).await; } } @@ -1946,7 +1947,7 @@ async fn initialize_lightpanda_manager( if Instant::now() >= deadline { return Err(lightpanda_target_init_timeout(Some(&err))); } - tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; + crate::rt::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; continue; } }; @@ -1975,7 +1976,7 @@ async fn initialize_lightpanda_manager( if Instant::now() >= deadline { return Err(lightpanda_target_init_timeout(Some(&err))); } - tokio::time::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; + crate::rt::sleep(LIGHTPANDA_CDP_CONNECT_POLL_INTERVAL).await; } } } @@ -2008,7 +2009,7 @@ where let remaining = remaining_until(deadline) .ok_or_else(|| lightpanda_target_init_timeout(Some("deadline expired before retry")))?; - match tokio::time::timeout(remaining, operation).await { + match crate::rt::timeout(remaining, operation).await { Ok(result) => result, Err(_) => Err(lightpanda_target_init_timeout(Some(timeout_context))), } @@ -2067,7 +2068,7 @@ async fn resolve_cdp_url(input: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use tokio::time::sleep; + use crate::rt::sleep; #[test] fn test_format_tab_id() { @@ -2382,7 +2383,7 @@ mod tests { #[tokio::test] async fn test_run_with_lightpanda_deadline_enforces_timeout() { let deadline = Instant::now() + Duration::from_millis(25); - let err = tokio::time::timeout( + let err = crate::rt::timeout( Duration::from_secs(1), run_with_lightpanda_deadline( deadline, @@ -2474,8 +2475,8 @@ mod tests { let (tx, mut rx) = broadcast::channel::(16); let session = "s1"; - let start = tokio::time::Instant::now(); - let result = tokio::time::timeout( + let start = crate::rt::Instant::now(); + let result = crate::rt::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) @@ -2501,7 +2502,7 @@ mod tests { let session = "s1"; let _keep_alive = tx.clone(); - tokio::spawn(async move { + crate::rt::spawn(async move { sleep(Duration::from_millis(50)).await; let _ = tx.send(cdp_event( "Network.requestWillBeSent", @@ -2516,8 +2517,8 @@ mod tests { )); }); - let start = tokio::time::Instant::now(); - let result = tokio::time::timeout( + let start = crate::rt::Instant::now(); + let result = crate::rt::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) @@ -2540,7 +2541,7 @@ mod tests { let session = "s1"; let _keep_alive = tx.clone(); - tokio::spawn(async move { + crate::rt::spawn(async move { sleep(Duration::from_millis(50)).await; let _ = tx.send(cdp_event( "Network.requestWillBeSent", @@ -2568,8 +2569,8 @@ mod tests { )); }); - let start = tokio::time::Instant::now(); - let result = tokio::time::timeout( + let start = crate::rt::Instant::now(); + let result = crate::rt::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) @@ -2594,7 +2595,7 @@ mod tests { let session = "s1"; // Keep sending requests so idle is never reached - tokio::spawn(async move { + crate::rt::spawn(async move { for i in 0u64.. { let _ = tx.send(cdp_event( "Network.requestWillBeSent", @@ -2630,7 +2631,7 @@ mod tests { listener.local_addr().unwrap().port() ); - tokio::spawn(async move { + crate::rt::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -2719,7 +2720,7 @@ mod tests { assert_eq!(mgr.pages.len(), 2); assert_eq!(mgr.active_page_index, 0); - let result = tokio::time::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) + let result = crate::rt::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang on a discarded tab") .expect("switching to a discarded tab should revive it"); @@ -2742,7 +2743,7 @@ mod tests { let url = start_mock_cdp_browser_with_discarded_tab(false).await; let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); - let err = tokio::time::timeout(Duration::from_secs(25), mgr.tab_switch(1, None)) + let err = crate::rt::timeout(Duration::from_secs(25), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang on a dead tab") .expect_err("switching to an unrevivable tab should fail"); @@ -2777,7 +2778,7 @@ mod tests { listener.local_addr().unwrap().port() ); - tokio::spawn(async move { + crate::rt::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -2841,7 +2842,7 @@ mod tests { let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); assert_eq!(mgr.pages.len(), 2); - let result = tokio::time::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) + let result = crate::rt::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang") .expect("switching to a responsive tab should succeed"); @@ -2864,13 +2865,13 @@ mod tests { let url = start_mock_cdp_browser_with_discarded_tab(true).await; let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); - let _ = tokio::time::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) + let _ = crate::rt::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang") .expect("switching to a discarded tab should revive it"); // Cleanup runs on the guard's drop via a spawned task; let it settle. - tokio::time::sleep(Duration::from_millis(200)).await; + crate::rt::sleep(Duration::from_millis(200)).await; assert_eq!( mgr.client.pending_len().await, @@ -2900,7 +2901,7 @@ mod tests { listener.local_addr().unwrap().port() ); - tokio::spawn(async move { + crate::rt::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -2969,7 +2970,7 @@ mod tests { let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); assert_eq!(mgr.pages.len(), 2); - let result = tokio::time::timeout( + let result = crate::rt::timeout( Duration::from_secs(20), mgr.tab_switch(1, Some("S-T-BLOCKED")), ) @@ -3005,7 +3006,7 @@ mod tests { assert_eq!(mgr.active_page_index, 0); // Close the live active tab; the discarded tab becomes the successor. - let result = tokio::time::timeout(Duration::from_secs(20), mgr.tab_close(Some(0), None)) + let result = crate::rt::timeout(Duration::from_secs(20), mgr.tab_close(Some(0), None)) .await .expect("tab_close must not hang on a discarded successor") .expect("closing a tab with a discarded successor should succeed"); @@ -3029,7 +3030,7 @@ mod tests { let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); assert_eq!(mgr.pages.len(), 2); - let result = tokio::time::timeout(Duration::from_secs(25), mgr.tab_close(Some(0), None)) + let result = crate::rt::timeout(Duration::from_secs(25), mgr.tab_close(Some(0), None)) .await .expect("tab_close must not hang") .expect("a committed close must report success even if the successor is dead"); @@ -3091,7 +3092,7 @@ mod tests { .map(|(id, _)| format!("S-{}", id)) .collect(); - tokio::spawn(async move { + crate::rt::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -3164,7 +3165,7 @@ mod tests { let (url, activations) = start_mock_cdp_connect(vec![("DISCARDED", false), ("ALIVE", true)], false).await; - let mgr = tokio::time::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) + let mgr = crate::rt::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) .await .expect("connect must not hang on a discarded first target") .expect("connect should succeed by selecting the live tab"); @@ -3195,7 +3196,7 @@ mod tests { let targets: Vec<(&'static str, bool)> = ids.iter().map(|id| (*id, true)).collect(); let (url, activations) = start_mock_cdp_connect(targets, false).await; - let mgr = tokio::time::timeout(Duration::from_secs(5), BrowserManager::connect_cdp(&url)) + let mgr = crate::rt::timeout(Duration::from_secs(5), BrowserManager::connect_cdp(&url)) .await .expect("connect with many live tabs must stay fast") .expect("connect should succeed"); @@ -3219,7 +3220,7 @@ mod tests { async fn test_connect_all_discarded_revives_first_tab() { let (url, activations) = start_mock_cdp_connect(vec![("ONLY", false)], true).await; - let mgr = tokio::time::timeout(Duration::from_secs(20), BrowserManager::connect_cdp(&url)) + let mgr = crate::rt::timeout(Duration::from_secs(20), BrowserManager::connect_cdp(&url)) .await .expect("connect must not hang when all tabs are discarded") .expect("connect should succeed after reviving the only tab"); @@ -3237,10 +3238,9 @@ mod tests { async fn test_connect_all_discarded_unrevivable_fails_fast() { let (url, _activations) = start_mock_cdp_connect(vec![("ONLY", false)], false).await; - let result = - tokio::time::timeout(Duration::from_secs(30), BrowserManager::connect_cdp(&url)) - .await - .expect("connect must not hang on an unrevivable discarded tab"); + let result = crate::rt::timeout(Duration::from_secs(30), BrowserManager::connect_cdp(&url)) + .await + .expect("connect must not hang on an unrevivable discarded tab"); match result { Ok(_) => panic!("connect should fail when no tab can be made live"), @@ -3264,7 +3264,7 @@ mod tests { ) .await; - let mgr = tokio::time::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) + let mgr = crate::rt::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) .await .expect("connect must not hang") .expect("connect should select the live tab"); @@ -3274,7 +3274,7 @@ mod tests { ); // Drop-guard cleanup runs on a spawned task; let it settle. - tokio::time::sleep(Duration::from_millis(300)).await; + crate::rt::sleep(Duration::from_millis(300)).await; assert_eq!( mgr.client.pending_len().await, 0, diff --git a/cli/src/native/cdp/client.rs b/cli/src/native/cdp/client.rs index 50827d1e2..168324921 100644 --- a/cli/src/native/cdp/client.rs +++ b/cli/src/native/cdp/client.rs @@ -1,19 +1,65 @@ use std::collections::HashMap; use std::io::Write; +use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use futures_util::{SinkExt, StreamExt}; +#[cfg(not(target_arch = "wasm32"))] +use futures_util::SinkExt; +use futures_util::{Stream, StreamExt}; use serde_json::Value; use tokio::sync::{broadcast, oneshot, Mutex}; +#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::tungstenite::client::IntoClientRequest; +#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::tungstenite::Message; use super::types::{CdpCommand, CdpEvent, CdpMessage}; type PendingMap = Arc>>>; +/// Incoming message from a CDP transport, decoupled from any specific +/// WebSocket implementation. +#[derive(Debug)] +pub enum TransportEvent { + Text(String), + Close(Option), + Error(String), +} + +/// Outgoing half of a CDP transport. Implemented by tokio-tungstenite on +/// native targets; other runtimes (e.g. Cloudflare Workers) supply their own. +#[cfg(not(target_arch = "wasm32"))] +#[async_trait::async_trait] +pub trait CdpTransportSink: Send + Sync { + async fn send_text(&self, text: String) -> Result<(), String>; + + /// Send a protocol-level ping frame. Transports without ping support + /// treat this as a no-op. + async fn send_ping(&self) -> Result<(), String> { + Ok(()) + } +} + +#[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] +pub trait CdpTransportSink { + async fn send_text(&self, text: String) -> Result<(), String>; + + /// Send a protocol-level ping frame. Transports without ping support + /// treat this as a no-op. + async fn send_ping(&self) -> Result<(), String> { + Ok(()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub type TransportEventStream = Pin + Send>>; +#[cfg(target_arch = "wasm32")] +pub type TransportEventStream = Pin>>; + /// Interval between WebSocket ping frames sent to keep the connection alive /// through intermediate proxies (reverse proxies, load balancers, service meshes). const WS_KEEPALIVE_INTERVAL_SECS: u64 = 30; @@ -27,22 +73,13 @@ pub struct RawCdpMessage { } pub struct CdpClient { - ws_tx: Arc< - Mutex< - futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - Message, - >, - >, - >, + ws_tx: Arc, next_id: AtomicU64, pending: PendingMap, event_tx: broadcast::Sender, raw_tx: broadcast::Sender, - _reader_handle: tokio::task::JoinHandle<()>, - _keepalive_handle: tokio::task::JoinHandle<()>, + _reader_handle: crate::rt::JoinHandle<()>, + _keepalive_handle: crate::rt::JoinHandle<()>, } /// Removes a pending entry if `send_command` is cancelled mid-await (e.g. an @@ -62,19 +99,78 @@ impl Drop for PendingGuard { } let pending = self.pending.clone(); let id = self.id; + #[cfg(not(target_arch = "wasm32"))] if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { pending.lock().await.remove(&id); }); } + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(async move { + pending.lock().await.remove(&id); + }); + } +} + +/// tokio-tungstenite implementation of the transport sink used on native targets. +#[cfg(not(target_arch = "wasm32"))] +struct TungsteniteSink { + tx: Mutex< + futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + >, +} + +#[cfg(not(target_arch = "wasm32"))] +#[async_trait::async_trait] +impl CdpTransportSink for TungsteniteSink { + async fn send_text(&self, text: String) -> Result<(), String> { + let mut tx = self.tx.lock().await; + tx.send(Message::Text(text)) + .await + .map_err(|e| e.to_string()) + } + + async fn send_ping(&self) -> Result<(), String> { + let mut tx = self.tx.lock().await; + tx.send(Message::Ping(Vec::new())) + .await + .map_err(|e| e.to_string()) } } impl CdpClient { + #[cfg(target_arch = "wasm32")] + pub async fn connect(_url: &str) -> Result { + Err( + "direct WebSocket connect is not supported on this platform; \ + construct the client with CdpClient::from_transport" + .to_string(), + ) + } + + #[cfg(target_arch = "wasm32")] + pub async fn connect_with_headers( + _url: &str, + _headers: Option>, + ) -> Result { + Err( + "direct WebSocket connect is not supported on this platform; \ + construct the client with CdpClient::from_transport" + .to_string(), + ) + } + + #[cfg(not(target_arch = "wasm32"))] pub async fn connect(url: &str) -> Result { Self::connect_with_headers(url, None).await } + #[cfg(not(target_arch = "wasm32"))] pub async fn connect_with_headers( url: &str, headers: Option>, @@ -108,8 +204,40 @@ impl CdpClient { enable_tcp_keepalive(ws_stream.get_ref()); - let (ws_tx, mut ws_rx) = ws_stream.split(); - let ws_tx = Arc::new(Mutex::new(ws_tx)); + let (ws_tx, ws_rx) = ws_stream.split(); + + // Accept both Text and Binary frames — remote CDP proxies + // (e.g. Browserless) may send responses as Binary frames. + let events: TransportEventStream = Box::pin(ws_rx.filter_map(|msg| async move { + match msg { + Ok(Message::Text(text)) => Some(TransportEvent::Text(text)), + Ok(Message::Binary(data)) => String::from_utf8(data).ok().map(TransportEvent::Text), + Ok(Message::Close(frame)) => Some(TransportEvent::Close( + frame + .as_ref() + .map(|f| format!("code={}, reason={}", f.code, f.reason)), + )), + Ok(_) => None, + Err(e) => Some(TransportEvent::Error(e.to_string())), + } + })); + + Ok(Self::from_transport( + Arc::new(TungsteniteSink { + tx: Mutex::new(ws_tx), + }), + events, + )) + } + + /// Build a client on top of an already-established transport. This is the + /// runtime-injection seam: any WebSocket-like transport that can deliver + /// text frames works, regardless of the underlying platform. + pub fn from_transport( + sink: Arc, + mut events: TransportEventStream, + ) -> Self { + let ws_tx = sink; let pending: PendingMap = Arc::new(Mutex::new(HashMap::new())); let (event_tx, _) = broadcast::channel(4096); @@ -122,30 +250,19 @@ impl CdpClient { // Notify used to stop the keepalive task when the reader loop exits. let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false); - let reader_handle = tokio::spawn(async move { - while let Some(msg) = ws_rx.next().await { - // Accept both Text and Binary frames — remote CDP proxies - // (e.g. Browserless) may send responses as Binary frames. - let msg = match msg { - Ok(Message::Text(text)) => text, - Ok(Message::Binary(data)) => match String::from_utf8(data) { - Ok(text) => text, - Err(_) => continue, - }, - Ok(Message::Close(frame)) => { + let reader_handle = crate::rt::spawn(async move { + while let Some(event) = events.next().await { + let msg = match event { + TransportEvent::Text(text) => text, + TransportEvent::Close(reason) => { if std::env::var("AGENT_BROWSER_DEBUG").is_ok() { - let reason = frame - .as_ref() - .map(|f| format!("code={}, reason={}", f.code, f.reason)) - .unwrap_or_else(|| "no frame".to_string()); + let reason = reason.unwrap_or_else(|| "no frame".to_string()); let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Close: {}", reason); } break; } - Ok(Message::Pong(_)) => continue, - Ok(_) => continue, - Err(e) => { + TransportEvent::Error(e) => { if std::env::var("AGENT_BROWSER_DEBUG").is_ok() { let _ = writeln!(std::io::stderr(), "[cdp] WebSocket Error: {}", e); } @@ -203,21 +320,20 @@ impl CdpClient { // cloud load balancers) from closing idle WebSocket connections. If the // send fails, the connection is dead and we stop pinging. let keepalive_tx = ws_tx.clone(); - let keepalive_handle = tokio::spawn(async move { + let keepalive_handle = crate::rt::spawn(async move { let interval = std::time::Duration::from_secs(WS_KEEPALIVE_INTERVAL_SECS); loop { tokio::select! { - _ = tokio::time::sleep(interval) => {} + _ = crate::rt::sleep(interval) => {} _ = cancel_rx.changed() => break, } - let mut tx = keepalive_tx.lock().await; - if tx.send(Message::Ping(Vec::new())).await.is_err() { + if keepalive_tx.send_ping().await.is_err() { break; } } }); - Ok(Self { + Self { ws_tx, next_id: AtomicU64::new(1), pending, @@ -225,7 +341,7 @@ impl CdpClient { raw_tx, _reader_handle: reader_handle, _keepalive_handle: keepalive_handle, - }) + } } pub async fn send_command( @@ -260,15 +376,12 @@ impl CdpClient { done: false, }; - { - let mut ws_tx = self.ws_tx.lock().await; - ws_tx - .send(Message::Text(json)) - .await - .map_err(|e| format!("Failed to send CDP command: {}", e))?; - } + self.ws_tx + .send_text(json) + .await + .map_err(|e| format!("Failed to send CDP command: {}", e))?; - let response = match tokio::time::timeout(std::time::Duration::from_secs(30), rx).await { + let response = match crate::rt::timeout(std::time::Duration::from_secs(30), rx).await { Ok(Ok(resp)) => { guard.done = true; resp @@ -355,9 +468,8 @@ impl CdpClient { let json = serde_json::to_string(&cmd) .map_err(|e| format!("Failed to serialize CDP command: {}", e))?; - let mut ws_tx = self.ws_tx.lock().await; - ws_tx - .send(Message::Text(json)) + self.ws_tx + .send_text(json) .await .map_err(|e| format!("Failed to send CDP command: {}", e)) } @@ -365,9 +477,8 @@ impl CdpClient { /// Send raw JSON through the WebSocket without tracking a response. /// Used by the inspect proxy to forward DevTools frontend messages. pub async fn send_raw(&self, json: String) -> Result<(), String> { - let mut ws_tx = self.ws_tx.lock().await; - ws_tx - .send(Message::Text(json)) + self.ws_tx + .send_text(json) .await .map_err(|e| format!("Failed to send raw CDP message: {}", e)) } @@ -380,29 +491,17 @@ impl CdpClient { } } -type WsTx = Arc< - Mutex< - futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - Message, - >, - >, ->; - /// Lightweight handle for the inspect WebSocket proxy, holding only /// the cloneable parts of CdpClient needed for bidirectional message forwarding. pub struct InspectProxyHandle { - ws_tx: WsTx, + ws_tx: Arc, raw_tx: broadcast::Sender, } impl InspectProxyHandle { pub async fn send_raw(&self, json: String) -> Result<(), String> { - let mut ws_tx = self.ws_tx.lock().await; - ws_tx - .send(Message::Text(json)) + self.ws_tx + .send_text(json) .await .map_err(|e| format!("Failed to send raw CDP message: {}", e)) } @@ -415,6 +514,7 @@ impl InspectProxyHandle { /// Enable TCP SO_KEEPALIVE on the underlying socket of a WebSocket connection. /// This is best-effort: failures are silently ignored since the WebSocket-level /// Ping keepalive provides the primary connection liveness mechanism. +#[cfg(not(target_arch = "wasm32"))] fn enable_tcp_keepalive(stream: &tokio_tungstenite::MaybeTlsStream) { let tcp_stream = match stream { tokio_tungstenite::MaybeTlsStream::Plain(s) => s, From dbef264536b707bcd95c26ce58d211b71cb12f07 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:53:36 +0000 Subject: [PATCH 04/11] feat: gate native-only modules and dependencies for wasm32 target Co-Authored-By: glavin@coframe.com --- cli/Cargo.lock | 3 + cli/Cargo.toml | 22 ++++-- cli/src/connection.rs | 21 +++++ cli/src/install.rs | 36 +++++++-- cli/src/native/actions.rs | 122 ++++++++++++++++------------- cli/src/native/cdp/chrome.rs | 39 ++++++--- cli/src/native/cdp/discovery.rs | 24 ++++-- cli/src/native/cdp/lightpanda.rs | 14 ++-- cli/src/native/inspect_server.rs | 48 ++++++++++-- cli/src/native/interaction.rs | 2 +- cli/src/native/mod.rs | 1 + cli/src/native/recording.rs | 37 +++++++-- cli/src/native/state.rs | 8 +- cli/src/native/stream/mod.rs | 68 ++++++++++------ cli/src/native/tracing.rs | 14 +++- cli/src/native/webdriver/appium.rs | 14 +++- cli/src/native/webdriver/client.rs | 9 ++- cli/src/plugins.rs | 47 ++++++++++- cli/src/read.rs | 61 +++++++++------ 19 files changed, 419 insertions(+), 171 deletions(-) diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 2358ef6e9..f5ce8a6b1 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -57,6 +57,7 @@ dependencies = [ "hex", "hmac", "image", + "js-sys", "libc", "reqwest", "rust-embed", @@ -72,6 +73,8 @@ dependencies = [ "url", "urlencoding", "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", "windows-sys 0.52.0", "zip", ] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index cebad39ae..f5d06811d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -13,20 +13,15 @@ categories = ["command-line-utilities", "web-programming"] [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -dirs = "5.0" base64 = "0.22" getrandom = "0.2" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] } -tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" url = "2" uuid = { version = "1", features = ["v4"] } image = "0.25" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] } sha2 = "0.10" aes-gcm = "0.10" async-trait = "0.1" -socket2 = "0.6" similar = "2" zip = { version = "8.2.0", default-features = false, features = ["deflate"] } time = { version = "0.3", features = ["formatting"] } @@ -36,6 +31,23 @@ chrono = "0.4" urlencoding = "2" rust-embed = "8" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +dirs = "5.0" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "sync", "signal", "process"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] } +socket2 = "0.6" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { version = "1", features = ["macros", "sync"] } +getrandom = { version = "0.2", features = ["js"] } +uuid = { version = "1", features = ["v4", "js"] } +dirs = "5.0" +reqwest = { version = "0.12", default-features = false, features = ["json", "stream"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/cli/src/connection.rs b/cli/src/connection.rs index cc584a465..43ca3d2a1 100644 --- a/cli/src/connection.rs +++ b/cli/src/connection.rs @@ -7,6 +7,7 @@ use std::hash::{Hash, Hasher}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; use std::path::PathBuf; +#[cfg(any(unix, windows))] use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -183,6 +184,11 @@ pub fn cleanup_stale_files(session: &str) { /// so we don't mis-clean a live daemon owned by a different uid. Only ESRCH /// ("no such process") is treated as dead. pub fn is_pid_alive(pid: u32) -> bool { + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + false + } #[cfg(unix)] unsafe { if libc::kill(pid as i32, 0) == 0 { @@ -397,6 +403,11 @@ pub fn resolve_port(session: &str) -> u16 { } pub fn daemon_ready(session: &str) -> bool { + #[cfg(not(any(unix, windows)))] + { + let _ = session; + false + } #[cfg(unix)] { let socket_path = get_socket_path(session); @@ -465,6 +476,7 @@ pub struct DaemonOptions<'a> { pub plugins: Option<&'a str>, } +#[cfg(any(unix, windows))] fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) { cmd.env("AGENT_BROWSER_DAEMON", "1") .env("AGENT_BROWSER_SESSION", session); @@ -704,6 +716,7 @@ fn daemon_version_matches(session: &str) -> bool { } /// Kill a running daemon by reading its PID file and sending a kill signal. +#[cfg_attr(not(any(unix, windows)), allow(unused_variables))] fn kill_stale_daemon(session: &str) { // Remove the socket first so no new connections reach the old daemon #[cfg(unix)] @@ -780,6 +793,7 @@ fn stop_existing_daemon_for_restart(session: &str) { } } +#[cfg_attr(not(any(unix, windows)), allow(unused_variables, unused_mut))] pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result { let mut restarted = false; @@ -980,11 +994,18 @@ pub fn ensure_daemon(session: &str, opts: &DaemonOptions) -> Result Result { + #[cfg(not(any(unix, windows)))] + { + let _ = session; + Err("daemon connections are not supported on this platform".to_string()) + } #[cfg(unix)] { let socket_path = get_socket_path(session); diff --git a/cli/src/install.rs b/cli/src/install.rs index e43f35b64..9b28adb12 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -1,9 +1,12 @@ +#[cfg(not(target_arch = "wasm32"))] use crate::color; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +#[cfg(not(target_arch = "wasm32"))] use std::process::{exit, Command, ExitStatus, Stdio}; +#[cfg(not(target_arch = "wasm32"))] const LAST_KNOWN_GOOD_URL: &str = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"; @@ -98,6 +101,10 @@ pub fn find_installed_chrome() -> Option { None } +#[cfg_attr( + not(any(target_os = "macos", target_os = "linux", target_os = "windows")), + allow(unused_variables) +)] fn chrome_binary_in_dir(dir: &Path) -> Option { #[cfg(target_os = "macos")] { @@ -151,6 +158,7 @@ fn chrome_binary_in_dir(dir: &Path) -> Option { } } +#[cfg(not(target_arch = "wasm32"))] fn platform_key() -> &'static str { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { @@ -182,6 +190,7 @@ fn platform_key() -> &'static str { } } +#[cfg(not(target_arch = "wasm32"))] async fn fetch_download_url() -> Result<(String, String), String> { let client = http_client()?; let resp = client @@ -226,6 +235,7 @@ async fn fetch_download_url() -> Result<(String, String), String> { Ok((version, url)) } +#[cfg(not(target_arch = "wasm32"))] fn format_reqwest_error(e: &reqwest::Error) -> String { let mut msg = e.to_string(); let mut source = std::error::Error::source(e); @@ -236,6 +246,7 @@ fn format_reqwest_error(e: &reqwest::Error) -> String { msg } +#[cfg(not(target_arch = "wasm32"))] fn http_client() -> Result { reqwest::Client::builder() .user_agent(format!("agent-browser/{}", env!("CARGO_PKG_VERSION"))) @@ -245,6 +256,7 @@ fn http_client() -> Result { .map_err(|e| format!("Failed to create HTTP client: {}", format_reqwest_error(&e))) } +#[cfg(not(target_arch = "wasm32"))] async fn download_bytes(url: &str) -> Result, String> { let client = http_client()?; let max_retries = 3; @@ -257,7 +269,7 @@ async fn download_bytes(url: &str) -> Result, String> { attempt + 1, max_retries ); - tokio::time::sleep(std::time::Duration::from_secs(1 << attempt)).await; + crate::rt::sleep(std::time::Duration::from_secs(1 << attempt)).await; } let resp = match client.get(url).send().await { @@ -332,6 +344,7 @@ async fn download_bytes(url: &str) -> Result, String> { Err(last_err) } +#[cfg(not(target_arch = "wasm32"))] fn extract_zip(bytes: Vec, dest: &Path) -> Result<(), String> { fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?; @@ -397,6 +410,7 @@ fn extract_zip(bytes: Vec, dest: &Path) -> Result<(), String> { Ok(()) } +#[cfg(not(target_arch = "wasm32"))] pub fn run_install(with_deps: bool) { if cfg!(all(target_os = "linux", target_arch = "aarch64")) { eprintln!( @@ -497,6 +511,7 @@ pub fn run_install(with_deps: bool) { } } +#[cfg(not(target_arch = "wasm32"))] fn install_status_result(status: io::Result) -> Result<(), String> { match status { Ok(s) if s.success() => Ok(()), @@ -510,6 +525,7 @@ fn install_status_result(status: io::Result) -> Result<(), String> { } } +#[cfg(not(target_arch = "wasm32"))] fn report_install_status(status: io::Result) { match install_status_result(status) { Ok(()) => { @@ -530,6 +546,7 @@ fn report_install_status(status: io::Result) { } } +#[cfg(not(target_arch = "wasm32"))] fn apt_dependency_specs() -> Vec<(&'static str, Option<&'static str>)> { vec![ ("libxcb-shm0", None), @@ -572,6 +589,7 @@ fn apt_dependency_specs() -> Vec<(&'static str, Option<&'static str>)> { ] } +#[cfg(not(target_arch = "wasm32"))] fn resolve_apt_deps_with(mut package_exists: F) -> Vec<&'static str> where F: FnMut(&str) -> bool, @@ -589,10 +607,12 @@ where .collect() } +#[cfg(not(target_arch = "wasm32"))] fn resolve_apt_deps() -> Vec<&'static str> { resolve_apt_deps_with(package_exists_apt) } +#[cfg(not(target_arch = "wasm32"))] fn install_linux_deps() { println!("{}", color::cyan("Installing system dependencies...")); @@ -783,6 +803,7 @@ fn install_linux_deps() { } } +#[cfg(not(target_arch = "wasm32"))] fn which_exists(cmd: &str) -> bool { #[cfg(unix)] { @@ -806,6 +827,7 @@ fn which_exists(cmd: &str) -> bool { } } +#[cfg(not(target_arch = "wasm32"))] fn package_exists_apt(pkg: &str) -> bool { Command::new("apt-cache") .arg("show") @@ -907,7 +929,7 @@ mod tests { let body = b"fake-zip-content"; let resp = http_response(200, "OK", body); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { accept_once(&listener, &resp).await; }); @@ -924,7 +946,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let resp = http_response(404, "Not Found", b"not found"); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { accept_once(&listener, &resp).await; }); @@ -945,7 +967,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { // First two attempts: 500 let r500 = http_response(500, "Internal Server Error", b"error"); accept_once(&listener, &r500).await; @@ -971,7 +993,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { let r500 = http_response(500, "Internal Server Error", b"error"); // All 3 attempts get 500 accept_once(&listener, &r500).await; @@ -997,7 +1019,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let resp = http_response(403, "Forbidden", b"forbidden"); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { // Only one request should arrive (no retries for 4xx) accept_once(&listener, &resp).await; }); @@ -1015,7 +1037,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let resp = http_response(200, "OK", b"ok"); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { let req = accept_with_ua_check(&listener, &resp).await; req }); diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 59f010544..baee38a39 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -356,10 +356,10 @@ pub struct DaemonState { /// When the most recent browser-touching command finished. Periodic /// autosaves wait for a quiet period after this so a multi-second save /// never lands in the middle of an active command burst. - pub last_command_finished: Option, + pub last_command_finished: Option, /// When session state was last saved or a periodic autosave last failed, /// used to enforce the minimum interval between periodic saves. - pub last_autosave_attempt: Option, + pub last_autosave_attempt: Option, pub session_id: String, pub tracing_state: TracingState, pub recording_state: RecordingState, @@ -399,10 +399,10 @@ pub struct DaemonState { /// Background task that processes Fetch.requestPaused events in real-time, /// handling domain filtering, route interception, and origin-scoped headers /// without deadlocking navigation/evaluate. - fetch_handler_task: Option>, + fetch_handler_task: Option>, /// Background task that auto-accepts `alert` and `beforeunload` dialogs /// so they never block the agent. - dialog_handler_task: Option>, + dialog_handler_task: Option>, pub mouse_state: MouseState, /// Tracks the currently open JavaScript dialog (alert/confirm/prompt), if any. pub pending_dialog: Option, @@ -451,6 +451,12 @@ fn default_idle_shutdown_is_blocked( is_webdriver || (!provider_owned && browser_blocks_shutdown) } +impl Default for DaemonState { + fn default() -> Self { + Self::new() + } +} + impl DaemonState { pub fn new() -> Self { Self { @@ -606,7 +612,7 @@ impl DaemonState { let origin_headers = self.origin_headers.clone(); let proxy_credentials = self.proxy_credentials.clone(); - self.fetch_handler_task = Some(tokio::spawn(async move { + self.fetch_handler_task = Some(crate::rt::spawn(async move { loop { match rx.recv().await { Ok(event) if event.method == "Fetch.authRequired" => { @@ -785,7 +791,7 @@ impl DaemonState { let client = browser.client.clone(); let mut rx = browser.client.subscribe(); - self.dialog_handler_task = Some(tokio::spawn(async move { + self.dialog_handler_task = Some(crate::rt::spawn(async move { loop { match rx.recv().await { Ok(event) if event.method == "Page.javascriptDialogOpening" => { @@ -2127,7 +2133,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { .unwrap_or("") .to_string(); - let cmd_start = std::time::Instant::now(); + let cmd_start = crate::rt::Instant::now(); if let Err(err) = validate_restore_config_from_command(cmd) { return error_response(&id, &err); @@ -2541,7 +2547,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { // active command burst to settle before collecting state. Stamped even on // error: a failed click can still have navigated. if !skip_launch { - state.last_command_finished = Some(std::time::Instant::now()); + state.last_command_finished = Some(crate::rt::Instant::now()); } let mut resp = match result { @@ -3627,7 +3633,7 @@ fn autosave_due(state: &DaemonState, interval_ms: u64) -> bool { if state.pending_dialog.is_some() { return false; } - let now = std::time::Instant::now(); + let now = crate::rt::Instant::now(); if let Some(t) = state.last_command_finished { if now.duration_since(t) < std::time::Duration::from_millis(AUTOSAVE_QUIET_PERIOD_MS) { return false; @@ -3663,7 +3669,7 @@ pub(crate) async fn maybe_autosave_restore_state(state: &mut DaemonState, interv if state.browser.is_none() { return; } - state.last_autosave_attempt = Some(std::time::Instant::now()); + state.last_autosave_attempt = Some(crate::rt::Instant::now()); let _ = auto_save_restore_state(state).await; } @@ -3721,7 +3727,7 @@ pub(crate) async fn auto_save_restore_state( state.restore_saved_path = Some(path.clone()); // Saves from any path (close, relaunch, restore-key change) reset // the periodic interval so the tick doesn't immediately re-save. - state.last_autosave_attempt = Some(std::time::Instant::now()); + state.last_autosave_attempt = Some(crate::rt::Instant::now()); Ok(Some(path)) } Err(err) => { @@ -4443,6 +4449,10 @@ async fn handle_inspect(state: &mut DaemonState) -> Result { Ok(json!({ "opened": true, "url": url })) } +#[cfg_attr( + not(any(target_os = "macos", target_os = "linux", target_os = "windows")), + allow(unused_variables) +)] fn open_url_in_browser(url: &str) { #[cfg(target_os = "macos")] let result = std::process::Command::new("open").arg(url).spawn(); @@ -5142,7 +5152,7 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.back().await?; - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(500)).await; let url = wb.get_url().await.unwrap_or_default(); state.ref_map.clear(); return Ok(json!({ "url": url })); @@ -5263,7 +5273,7 @@ async fn handle_back(state: &mut DaemonState) -> Result { } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; mgr.evaluate("history.back()", None).await?; - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(500)).await; let url = mgr.get_url().await.unwrap_or_default(); state.ref_map.clear(); Ok(json!({ "url": url })) @@ -5273,7 +5283,7 @@ async fn handle_forward(state: &mut DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.forward().await?; - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(500)).await; let url = wb.get_url().await.unwrap_or_default(); state.ref_map.clear(); return Ok(json!({ "url": url })); @@ -5281,7 +5291,7 @@ async fn handle_forward(state: &mut DaemonState) -> Result { } let mgr = state.browser.as_ref().ok_or("Browser not launched")?; mgr.evaluate("history.forward()", None).await?; - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(500)).await; let url = mgr.get_url().await.unwrap_or_default(); state.ref_map.clear(); Ok(json!({ "url": url })) @@ -5291,7 +5301,7 @@ async fn handle_reload(state: &mut DaemonState) -> Result { if let Some(ref wb) = state.webdriver_backend { if state.browser.is_none() { wb.reload().await?; - tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(1000)).await; let url = wb.get_url().await.unwrap_or_default(); state.ref_map.clear(); return Ok(json!({ "url": url })); @@ -5305,7 +5315,7 @@ async fn handle_reload(state: &mut DaemonState) -> Result { .await?; let mut rx = mgr.client.subscribe(); - let _ = tokio::time::timeout(tokio::time::Duration::from_secs(10), async { + let _ = crate::rt::timeout(crate::rt::Duration::from_secs(10), async { loop { match rx.recv().await { Ok(event) => { @@ -5372,7 +5382,7 @@ async fn wait_for_selector( } async fn wait_for_url(mgr: &BrowserManager, pattern: &str, timeout_ms: u64) -> Result<(), String> { - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); + let deadline = crate::rt::Instant::now() + crate::rt::Duration::from_millis(timeout_ms); loop { let url = mgr.get_url().await?; @@ -5380,11 +5390,11 @@ async fn wait_for_url(mgr: &BrowserManager, pattern: &str, timeout_ms: u64) -> R return Ok(()); } - if tokio::time::Instant::now() >= deadline { + if crate::rt::Instant::now() >= deadline { return Err(format!("Wait timed out after {}ms", timeout_ms)); } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(100)).await; } } @@ -5449,7 +5459,7 @@ async fn wait_for_selector_in_frame( let function = format!( "function() {{ const doc = this.contentDocument; if (!doc) return false; return {check}; }}", ); - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); + let deadline = crate::rt::Instant::now() + crate::rt::Duration::from_millis(timeout_ms); loop { let result = client .send_command( @@ -5470,10 +5480,10 @@ async fn wait_for_selector_in_frame( if satisfied { return Ok(()); } - if tokio::time::Instant::now() >= deadline { + if crate::rt::Instant::now() >= deadline { return Err(format!("Wait timed out after {}ms", timeout_ms)); } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(100)).await; } } @@ -5483,7 +5493,7 @@ async fn poll_until_true( expression: &str, timeout_ms: u64, ) -> Result<(), String> { - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); + let deadline = crate::rt::Instant::now() + crate::rt::Duration::from_millis(timeout_ms); loop { let result: super::cdp::types::EvaluateResult = client @@ -5508,11 +5518,11 @@ async fn poll_until_true( return Ok(()); } - if tokio::time::Instant::now() >= deadline { + if crate::rt::Instant::now() >= deadline { return Err(format!("Wait timed out after {}ms", timeout_ms)); } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(100)).await; } } @@ -6225,17 +6235,17 @@ async fn handle_download(cmd: &Value, state: &mut DaemonState) -> Result = None; loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let remaining = deadline.saturating_duration_since(crate::rt::Instant::now()); if remaining.is_zero() { return Err("Timeout waiting for download to complete".to_string()); } - match tokio::time::timeout(remaining, rx.recv()).await { + match crate::rt::timeout(remaining, rx.recv()).await { Ok(Ok(event)) => { // Browser-domain download events may arrive without a sessionId // or with a different sessionId than the page session, so we @@ -6287,7 +6297,7 @@ async fn handle_download(cmd: &Value, state: &mut DaemonState) -> Result Result Result Result Result Result { if event.method == "Network.responseReceived" && event.session_id.as_deref() == Some(&session_id) @@ -9090,15 +9100,15 @@ async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result { // Browser-domain events may arrive without a sessionId; // Page-domain events are matched by session. @@ -10296,7 +10306,7 @@ async fn wait_for_any_selector( selectors: &[&str], timeout_ms: u64, ) -> Result { - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); + let deadline = crate::rt::Instant::now() + crate::rt::Duration::from_millis(timeout_ms); loop { for selector in selectors { @@ -10349,11 +10359,11 @@ async fn wait_for_any_selector( } } - if tokio::time::Instant::now() >= deadline { + if crate::rt::Instant::now() >= deadline { return Err(format!("Wait timed out after {}ms", timeout_ms)); } - tokio::time::sleep(tokio::time::Duration::from_millis( + crate::rt::sleep(crate::rt::Duration::from_millis( AUTH_LOGIN_SELECTOR_POLL_INTERVAL_MS, )) .await; @@ -10612,11 +10622,11 @@ async fn handle_auth_login(cmd: &Value, state: &mut DaemonState) -> Result { if event.session_id.as_deref() == Some(&session_id) { @@ -10637,7 +10647,7 @@ async fn handle_auth_login(cmd: &Value, state: &mut DaemonState) -> Result 0 { - tokio::time::sleep(tokio::time::Duration::from_millis(fallback_sleep_ms)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(fallback_sleep_ms)).await; } } @@ -10761,7 +10771,7 @@ async fn handle_swipe(cmd: &Value, state: &mut DaemonState) -> Result Result, - ) -> (u16, tokio::task::JoinHandle) { + ) -> (u16, crate::rt::JoinHandle) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let handle = tokio::spawn(async move { + let handle = crate::rt::spawn(async move { let mut handled = 0; for (expected_path, body) in responses { let (mut stream, _) = listener.accept().await.unwrap(); @@ -11687,10 +11697,10 @@ mod tests { fn test_autosave_waits_for_quiet_period_after_command() { let mut state = DaemonState::new(); - state.last_command_finished = Some(std::time::Instant::now()); + state.last_command_finished = Some(crate::rt::Instant::now()); assert!(!autosave_due(&state, 30_000)); - state.last_command_finished = std::time::Instant::now().checked_sub( + state.last_command_finished = crate::rt::Instant::now().checked_sub( std::time::Duration::from_millis(AUTOSAVE_QUIET_PERIOD_MS + 1_000), ); assert!(state.last_command_finished.is_some()); @@ -11701,11 +11711,11 @@ mod tests { fn test_autosave_enforces_min_interval_between_attempts() { let mut state = DaemonState::new(); - state.last_autosave_attempt = Some(std::time::Instant::now()); + state.last_autosave_attempt = Some(crate::rt::Instant::now()); assert!(!autosave_due(&state, 30_000)); state.last_autosave_attempt = - std::time::Instant::now().checked_sub(std::time::Duration::from_secs(31)); + crate::rt::Instant::now().checked_sub(std::time::Duration::from_secs(31)); assert!(state.last_autosave_attempt.is_some()); assert!(autosave_due(&state, 30_000)); } diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 49e46091f..5294460fe 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -49,7 +49,7 @@ impl ChromeProcess { /// falling back to kill() if it doesn't exit within the timeout. /// This allows Chrome to flush cookies and other state to the user-data-dir. pub fn wait_or_kill(&mut self, timeout: Duration) { - let start = std::time::Instant::now(); + let start = crate::rt::Instant::now(); let poll_interval = Duration::from_millis(50); while start.elapsed() < timeout { @@ -248,7 +248,7 @@ fn maybe_start_xvfb(options: &LaunchOptions) -> Option { libc::fcntl(fds[0], libc::F_SETFL, flags | libc::O_NONBLOCK); } - let deadline = std::time::Instant::now() + Duration::from_secs(5); + let deadline = crate::rt::Instant::now() + Duration::from_secs(5); let mut buf: Vec = Vec::new(); loop { let mut chunk = [0u8; 16]; @@ -271,7 +271,7 @@ fn maybe_start_xvfb(options: &LaunchOptions) -> Option { } } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - if std::time::Instant::now() >= deadline { + if crate::rt::Instant::now() >= deadline { break; } std::thread::sleep(Duration::from_millis(50)); @@ -660,7 +660,7 @@ fn try_launch_chrome(chrome_path: &Path, options: &LaunchOptions) -> Result Result Result Result { let poll_interval = Duration::from_millis(50); - while std::time::Instant::now() <= deadline { + while crate::rt::Instant::now() <= deadline { if let Ok(Some(status)) = child.try_wait() { // Chrome exited before writing DevToolsActivePort -- report the // exit code so the caller can surface it alongside stderr output. @@ -764,13 +764,13 @@ fn wait_for_devtools_active_port( fn wait_for_ws_url_until( reader: BufReader, - deadline: std::time::Instant, + deadline: crate::rt::Instant, ) -> Result { let prefix = "DevTools listening on "; let mut stderr_lines: Vec = Vec::new(); for line in reader.lines() { - if std::time::Instant::now() > deadline { + if crate::rt::Instant::now() > deadline { return Err(chrome_launch_error( "Timeout waiting for Chrome DevTools URL", &stderr_lines, @@ -1002,12 +1002,18 @@ async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result bool { + false +} + +#[cfg(not(target_arch = "wasm32"))] async fn verify_ws_endpoint(ws_url: &str) -> bool { use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message; let timeout = Duration::from_secs(2); - let result = tokio::time::timeout(timeout, async { + let result = crate::rt::timeout(timeout, async { let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?; let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#; ws.send(Message::Text(cmd.into())).await.ok()?; @@ -1029,6 +1035,10 @@ async fn verify_ws_endpoint(ws_url: &str) -> bool { /// Returns the default Chrome user-data directory paths for the current platform. /// Includes Chrome, Chrome Canary, Chromium, and Brave. +#[cfg_attr( + not(any(target_os = "macos", target_os = "linux", target_os = "windows")), + allow(unused_mut) +)] pub fn get_chrome_user_data_dirs() -> Vec { let mut dirs = Vec::new(); @@ -1534,6 +1544,11 @@ fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf { chromium_dir.join("chrome-win/chrome.exe") } +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn build_playwright_binary_path(chromium_dir: &Path) -> PathBuf { + chromium_dir.join("chrome-linux/chrome") +} + fn expand_tilde(path: &str) -> String { if let Some(rest) = path.strip_prefix('~') { if let Some(home) = dirs::home_dir() { @@ -2421,7 +2436,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let ws_path = "/devtools/browser/test-uuid-1234".to_string(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { // accept: verify_ws_endpoint() WebSocket handshake let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); @@ -2458,7 +2473,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { // 1st accept: verify_ws_endpoint() ws_path probe — reject (just close) let (s1, _) = listener.accept().await.unwrap(); drop(s1); diff --git a/cli/src/native/cdp/discovery.rs b/cli/src/native/cdp/discovery.rs index 23d425918..b16afecb8 100644 --- a/cli/src/native/cdp/discovery.rs +++ b/cli/src/native/cdp/discovery.rs @@ -1,6 +1,8 @@ use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] use futures_util::{SinkExt, StreamExt}; +#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::tungstenite::Message; use super::types::BrowserVersionInfo; @@ -81,7 +83,7 @@ async fn fetch_cdp_info( ) -> Result { let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port); - let body = tokio::time::timeout(timeout, reqwest_get_string(&url)) + let body = crate::rt::timeout(timeout, reqwest_get_string(&url)) .await .map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))? .map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?; @@ -131,7 +133,7 @@ fn append_query(url: &str, query: Option<&str>) -> String { async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result { let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port); - let body = tokio::time::timeout(timeout, reqwest_get_string(&url)) + let body = crate::rt::timeout(timeout, reqwest_get_string(&url)) .await .map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))? .map_err(|e| { @@ -161,10 +163,16 @@ async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result Result { + Err("direct WebSocket discovery is not supported on this platform".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result { let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port); - tokio::time::timeout(timeout, async { + crate::rt::timeout(timeout, async { let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url) .await .map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?; @@ -234,7 +242,7 @@ mod tests { async fn discovers_ws_url_from_json_version() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { accept_http( &listener, &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#), @@ -251,7 +259,7 @@ mod tests { async fn returns_error_when_version_returns_invalid_json() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { accept_http(&listener, &http_200("not-json")).await; // /json/list and ws fallback both fail (server closes) }); @@ -265,7 +273,7 @@ mod tests { async fn falls_back_to_json_list_on_version_404() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { accept_http(&listener, HTTP_404).await; accept_http( &listener, @@ -283,7 +291,7 @@ mod tests { async fn falls_back_to_ws_when_http_returns_404() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { // /json/version -> 404, /json/list -> 404 accept_http(&listener, HTTP_404).await; accept_http(&listener, HTTP_404).await; @@ -370,7 +378,7 @@ mod tests { async fn discover_preserves_query_params() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = tokio::spawn(async move { + let server = crate::rt::spawn(async move { accept_http( &listener, &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#), diff --git a/cli/src/native/cdp/lightpanda.rs b/cli/src/native/cdp/lightpanda.rs index dae3d6b5e..0d5c57d4f 100644 --- a/cli/src/native/cdp/lightpanda.rs +++ b/cli/src/native/cdp/lightpanda.rs @@ -237,7 +237,7 @@ async fn wait_for_lightpanda_ready( logs: &LaunchLogBuffer, startup_timeout: Duration, ) -> Result { - let deadline = std::time::Instant::now() + startup_timeout; + let deadline = crate::rt::Instant::now() + startup_timeout; let mut last_probe_error = None; loop { @@ -246,7 +246,7 @@ async fn wait_for_lightpanda_ready( // before we snapshot them. This is best-effort: lines written just // before exit may still be missing, but the most useful output (early // startup errors) will already be in the buffer. - tokio::time::sleep(Duration::from_millis(25)).await; + crate::rt::sleep(Duration::from_millis(25)).await; return Err(lightpanda_launch_error( &format!( "Lightpanda exited before CDP became ready (status: {})", @@ -264,7 +264,7 @@ async fn wait_for_lightpanda_ready( Err(err) => last_probe_error = Some(err), } - if std::time::Instant::now() >= deadline { + if crate::rt::Instant::now() >= deadline { return Err(lightpanda_launch_error( &format!( "Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}", @@ -276,7 +276,7 @@ async fn wait_for_lightpanda_ready( )); } - tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await; + crate::rt::sleep(LIGHTPANDA_POLL_INTERVAL).await; } } @@ -331,7 +331,7 @@ mod tests { } async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) { - tokio::time::sleep(Duration::from_millis(delay_ms)).await; + crate::rt::sleep(Duration::from_millis(delay_ms)).await; let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap(); let (mut socket, _) = listener.accept().await.unwrap(); let mut buf = [0u8; 1024]; @@ -348,7 +348,7 @@ mod tests { #[tokio::test] async fn waits_for_ready_without_logs() { let port = unused_port(); - tokio::spawn(serve_json_version_once_after_delay( + crate::rt::spawn(serve_json_version_once_after_delay( port, 150, r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#, @@ -407,7 +407,7 @@ mod tests { let timeout = Duration::from_millis(300); let (logs, _drainers) = start_log_drainers(&mut child).unwrap(); - let err = tokio::time::timeout( + let err = crate::rt::timeout( Duration::from_secs(2), wait_for_lightpanda_ready(&mut child, port, &logs, timeout), ) diff --git a/cli/src/native/inspect_server.rs b/cli/src/native/inspect_server.rs index 1c151a4d8..1384f7a47 100644 --- a/cli/src/native/inspect_server.rs +++ b/cli/src/native/inspect_server.rs @@ -1,16 +1,25 @@ +#[cfg(not(target_arch = "wasm32"))] use std::io::Write; +#[cfg(not(target_arch = "wasm32"))] use std::sync::atomic::{AtomicI64, Ordering}; +#[cfg(not(target_arch = "wasm32"))] use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] use futures_util::{SinkExt, StreamExt}; +#[cfg(not(target_arch = "wasm32"))] use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +#[cfg(not(target_arch = "wasm32"))] use tokio::net::TcpListener; +#[cfg(not(target_arch = "wasm32"))] use tokio::sync::Mutex; +#[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::tungstenite::Message; use super::cdp::client::InspectProxyHandle; /// Counter for unique attach IDs so concurrent connections don't collide. +#[cfg(not(target_arch = "wasm32"))] static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000); /// Lightweight HTTP + WebSocket server for `agent-browser inspect`. @@ -22,9 +31,29 @@ static ATTACH_ID: AtomicI64 = AtomicI64::new(-1000); /// `sessionId` so the DevTools frontend sees a page-level view pub struct InspectServer { port: u16, - _handle: tokio::task::JoinHandle<()>, + _handle: crate::rt::JoinHandle<()>, } +#[cfg(target_arch = "wasm32")] +impl InspectServer { + pub async fn start( + _proxy_handle: InspectProxyHandle, + _target_id: String, + _chrome_host_port: String, + ) -> Result { + Err("inspect server is not supported on this platform".to_string()) + } + + pub fn port(&self) -> u16 { + self.port + } + + pub fn shutdown(self) { + self._handle.abort(); + } +} + +#[cfg(not(target_arch = "wasm32"))] impl InspectServer { /// Start the inspect proxy server. /// @@ -46,7 +75,7 @@ impl InspectServer { let proxy = Arc::new(proxy_handle); - let handle = tokio::spawn(accept_loop( + let handle = crate::rt::spawn(accept_loop( listener, proxy, target_id, @@ -69,6 +98,7 @@ impl InspectServer { } } +#[cfg(not(target_arch = "wasm32"))] async fn accept_loop( listener: TcpListener, proxy: Arc, @@ -86,7 +116,7 @@ async fn accept_loop( let tid = target_id.clone(); let chp = chrome_host_port.clone(); - tokio::spawn(async move { + crate::rt::spawn(async move { if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await { let _ = writeln!(std::io::stderr(), "[inspect] connection error: {}", e); } @@ -94,6 +124,7 @@ async fn accept_loop( } } +#[cfg(not(target_arch = "wasm32"))] async fn handle_connection( stream: tokio::net::TcpStream, proxy: Arc, @@ -132,8 +163,10 @@ async fn handle_connection( Ok(()) } +#[cfg(not(target_arch = "wasm32"))] const MAX_HEADER_BYTES: usize = 8192; +#[cfg(not(target_arch = "wasm32"))] async fn handle_http_redirect( buf_reader: BufReader, chrome_host_port: String, @@ -172,6 +205,7 @@ async fn handle_http_redirect( Ok(()) } +#[cfg(not(target_arch = "wasm32"))] async fn handle_ws_proxy( stream: tokio::net::TcpStream, proxy: Arc, @@ -200,7 +234,7 @@ async fn handle_ws_proxy( .map_err(|e| format!("Failed to send attachToTarget: {}", e))?; // Wait for the attachToTarget response to extract the session ID - let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async { + let session_id = crate::rt::timeout(std::time::Duration::from_secs(5), async { while let Ok(raw_msg) = raw_rx.recv().await { if let Ok(val) = serde_json::from_str::(&raw_msg.text) { if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) { @@ -229,7 +263,7 @@ async fn handle_ws_proxy( let session_id_clone = session_id.clone(); // Chrome -> DevTools: forward messages matching our session, strip sessionId - let mut chrome_to_devtools = tokio::spawn(async move { + let mut chrome_to_devtools = crate::rt::spawn(async move { loop { let raw_msg = match raw_rx.recv().await { Ok(msg) => msg, @@ -260,7 +294,7 @@ async fn handle_ws_proxy( // DevTools -> Chrome: inject sessionId and forward let proxy_for_send = proxy.clone(); let session_id_for_send = session_id.clone(); - let mut devtools_to_chrome = tokio::spawn(async move { + let mut devtools_to_chrome = crate::rt::spawn(async move { while let Some(Ok(msg)) = ws_rx.next().await { let text = match msg { Message::Text(t) => t, @@ -295,6 +329,7 @@ async fn handle_ws_proxy( Ok(()) } +#[cfg(not(target_arch = "wasm32"))] fn inject_session_id(json: &str, session_id: &str) -> String { if let Ok(mut val) = serde_json::from_str::(json) { if let Some(obj) = val.as_object_mut() { @@ -309,6 +344,7 @@ fn inject_session_id(json: &str, session_id: &str) -> String { } } +#[cfg(not(target_arch = "wasm32"))] fn strip_session_id(json: &str) -> String { if let Ok(mut val) = serde_json::from_str::(json) { if let Some(obj) = val.as_object_mut() { diff --git a/cli/src/native/interaction.rs b/cli/src/native/interaction.rs index e82b90550..ab7f06707 100644 --- a/cli/src/native/interaction.rs +++ b/cli/src/native/interaction.rs @@ -297,7 +297,7 @@ pub async fn type_text_into_active_context( } if delay > 0 { - tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(delay)).await; } } diff --git a/cli/src/native/mod.rs b/cli/src/native/mod.rs index ba62f5504..f4a793f2d 100644 --- a/cli/src/native/mod.rs +++ b/cli/src/native/mod.rs @@ -11,6 +11,7 @@ pub mod cdp; #[allow(dead_code)] pub mod cookies; #[allow(dead_code)] +#[cfg(not(target_arch = "wasm32"))] pub mod daemon; #[allow(dead_code)] pub mod diff; diff --git a/cli/src/native/recording.rs b/cli/src/native/recording.rs index cdd51c206..4a38d53f0 100644 --- a/cli/src/native/recording.rs +++ b/cli/src/native/recording.rs @@ -1,26 +1,38 @@ use serde_json::{json, Value}; +#[cfg(not(target_arch = "wasm32"))] use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] use tokio::io::AsyncWriteExt; use tokio::sync::oneshot; use super::cdp::client::CdpClient; +#[cfg(not(target_arch = "wasm32"))] use super::cdp::types::{CaptureScreenshotParams, CaptureScreenshotResult}; +#[cfg(not(target_arch = "wasm32"))] const CAPTURE_INTERVAL_MS: u64 = 100; +#[cfg(not(target_arch = "wasm32"))] const CAPTURE_FPS: u32 = 10; pub struct RecordingState { pub active: bool, pub output_path: String, pub frame_count: u64, - pub capture_task: Option>>, + pub capture_task: Option>>, pub shared_frame_count: Option>, pub cancel_tx: Option>, } +impl Default for RecordingState { + fn default() -> Self { + Self::new() + } +} + impl RecordingState { pub fn new() -> Self { Self { @@ -79,6 +91,7 @@ pub fn recording_restart(state: &mut RecordingState, path: &str) -> Result tokio::process::Command { let mut cmd = tokio::process::Command::new("ffmpeg"); @@ -122,14 +135,28 @@ fn build_ffmpeg_command(output_path: &str) -> tokio::process::Command { /// Spawn a background task that captures screenshots at a fixed interval /// and pipes them to ffmpeg in real-time. +#[cfg(target_arch = "wasm32")] +pub fn spawn_recording_task( + _client: Arc, + _session_id: String, + _output_path: String, + _shared_count: Arc, + _cancel_rx: oneshot::Receiver<()>, +) -> crate::rt::JoinHandle> { + crate::rt::spawn( + async move { Err("video recording is not supported on this platform".to_string()) }, + ) +} + +#[cfg(not(target_arch = "wasm32"))] pub fn spawn_recording_task( client: Arc, session_id: String, output_path: String, shared_count: Arc, cancel_rx: oneshot::Receiver<()>, -) -> tokio::task::JoinHandle> { - tokio::spawn(async move { +) -> crate::rt::JoinHandle> { + crate::rt::spawn(async move { let mut cancel_rx = std::pin::pin!(cancel_rx); let mut ffmpeg = build_ffmpeg_command(&output_path).spawn().map_err(|e| { @@ -144,8 +171,8 @@ pub fn spawn_recording_task( .take() .ok_or_else(|| "Failed to open ffmpeg stdin".to_string())?; - let mut interval = tokio::time::interval(Duration::from_millis(CAPTURE_INTERVAL_MS)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut interval = crate::rt::interval(Duration::from_millis(CAPTURE_INTERVAL_MS)); + interval.set_missed_tick_behavior(crate::rt::MissedTickBehavior::Skip); let params = CaptureScreenshotParams { format: Some("jpeg".to_string()), diff --git a/cli/src/native/state.rs b/cli/src/native/state.rs index e98eea74c..e361d1cfc 100644 --- a/cli/src/native/state.rs +++ b/cli/src/native/state.rs @@ -196,10 +196,10 @@ async fn collect_storage_in_target( } // Fulfill intercepted requests with blank HTML until the page loads - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5); + let deadline = crate::rt::Instant::now() + crate::rt::Duration::from_secs(5); let mut page_loaded = false; - while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(tokio::time::Duration::from_secs(2), event_rx.recv()).await { + while crate::rt::Instant::now() < deadline { + match crate::rt::timeout(crate::rt::Duration::from_secs(2), event_rx.recv()).await { Ok(Ok(evt)) if evt.session_id.as_deref() == Some(temp_session) => { if evt.method == "Fetch.requestPaused" { if let Some(request_id) = @@ -493,7 +493,7 @@ pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Res .await?; // Brief wait for navigation - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + crate::rt::sleep(crate::rt::Duration::from_millis(500)).await; for entry in &origin.local_storage { let js = format!( diff --git a/cli/src/native/stream/mod.rs b/cli/src/native/stream/mod.rs index 5f652bae9..8269a86ca 100644 --- a/cli/src/native/stream/mod.rs +++ b/cli/src/native/stream/mod.rs @@ -1,17 +1,25 @@ mod cdp_loop; +#[cfg(not(target_arch = "wasm32"))] pub(crate) mod chat; +#[cfg(not(target_arch = "wasm32"))] mod dashboard; +#[cfg(not(target_arch = "wasm32"))] mod discovery; +#[cfg(not(target_arch = "wasm32"))] mod http; +#[cfg(not(target_arch = "wasm32"))] mod websocket; pub use cdp_loop::{ack_screencast_frame, start_screencast, stop_screencast}; +#[cfg(not(target_arch = "wasm32"))] pub use dashboard::run_dashboard_server; +use crate::rt::Instant; use serde_json::{json, Value}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] use tokio::net::TcpListener; use tokio::sync::{broadcast, watch, Mutex, Notify, RwLock}; @@ -121,7 +129,7 @@ fn seq_in_serialized_frame(frame: &str) -> Option { /// The timestamp lets the idle-shutdown path re-check activity after waiting /// for a command to release the daemon state lock. The notification wakes the /// timer promptly for ordinary command and dashboard activity. -pub(crate) struct IdleActivity { +pub struct IdleActivity { last: std::sync::Mutex, notify: Notify, } @@ -199,8 +207,8 @@ pub struct StreamServer { last_engine: Arc>, recording: Arc>, shutdown_tx: watch::Sender, - accept_task: Mutex>>, - cdp_task: Mutex>>, + accept_task: Mutex>>, + cdp_task: Mutex>>, } impl StreamServer { @@ -305,6 +313,18 @@ impl StreamServer { } } + #[cfg(target_arch = "wasm32")] + async fn start_inner( + _preferred_port: u16, + _client_slot: Arc>>>, + _session_id: String, + _allow_port_fallback: bool, + _idle_activity: Arc, + ) -> Result<(Self, Arc>>>), String> { + Err("stream server is not supported on this platform".to_string()) + } + + #[cfg(not(target_arch = "wasm32"))] async fn start_inner( preferred_port: u16, client_slot: Arc>>>, @@ -358,7 +378,7 @@ impl StreamServer { let accept_shutdown_rx = shutdown_rx.clone(); let session_name_clone = session_id.clone(); let frame_watch_accept = frame_watch_rx.clone(); - let accept_task = tokio::spawn(async move { + let accept_task = crate::rt::spawn(async move { websocket::accept_loop( listener, frame_tx_clone, @@ -393,7 +413,7 @@ impl StreamServer { let recording_bg = recording.clone(); let frame_watch_bg = frame_watch_tx.clone(); let screencast_cfg_bg = screencast_config.clone(); - let cdp_task = tokio::spawn(async move { + let cdp_task = crate::rt::spawn(async move { cdp_loop::cdp_event_loop( frame_tx_bg, frame_watch_bg, @@ -738,7 +758,7 @@ mod tests { /// broken server fails instead of hanging. async fn next_frame(ws: &mut WsClient) -> Value { loop { - let msg = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) + let msg = crate::rt::timeout(std::time::Duration::from_secs(5), ws.next()) .await .expect("timed out waiting for frame") .expect("stream ended") @@ -836,14 +856,14 @@ mod tests { r#"{{"type":"frame","seq":{},"data":"{}"}}"#, seq, big )); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; + crate::rt::sleep(std::time::Duration::from_millis(10)).await; } assert!( Arc::strong_count(&idle) > connected_floor, "a connected client should hold the activity clock" ); - let teardown = tokio::time::Instant::now(); + let teardown = crate::rt::Instant::now(); server.shutdown().await; let took = teardown.elapsed(); assert!( @@ -859,7 +879,7 @@ mod tests { if Arc::strong_count(&idle) == 1 { break; } - tokio::time::sleep(std::time::Duration::from_millis(20)).await; + crate::rt::sleep(std::time::Duration::from_millis(20)).await; } assert_eq!( Arc::strong_count(&idle), @@ -911,9 +931,9 @@ mod tests { .expect("server start"); let mut ws = connect_client_to(server.port(), "/?maxFps=1").await; - tokio::time::sleep(std::time::Duration::from_millis(150)).await; + crate::rt::sleep(std::time::Duration::from_millis(150)).await; - let sent_at = tokio::time::Instant::now(); + let sent_at = crate::rt::Instant::now(); server.broadcast_frame(r#"{"type":"frame","seq":1,"data":"first"}"#); let frame = next_frame(&mut ws).await; assert_eq!(frame.get("seq").and_then(|v| v.as_u64()), Some(1)); @@ -943,7 +963,7 @@ mod tests { .await .expect("send config"); // Let the reader task apply the config before frames start flowing. - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + crate::rt::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","data":"first"}"#); let frame = next_frame(&mut ws).await; @@ -961,13 +981,13 @@ mod tests { /// Assert no frame arrives within `ms`. A plain `next_frame` would pass on /// the very behavior these tests forbid. async fn expect_no_frame(ws: &mut WsClient, ms: u64) { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(ms); + let deadline = crate::rt::Instant::now() + std::time::Duration::from_millis(ms); loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let remaining = deadline.saturating_duration_since(crate::rt::Instant::now()); if remaining.is_zero() { return; } - match tokio::time::timeout(remaining, ws.next()).await { + match crate::rt::timeout(remaining, ws.next()).await { Err(_) => return, Ok(Some(Ok(Message::Text(text)))) => { let parsed: Value = serde_json::from_str(&text).expect("valid json"); @@ -1002,7 +1022,7 @@ mod tests { )) .await .expect("send config"); - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + crate::rt::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","seq":1,"data":"one"}"#); let frame = next_frame(&mut ws).await; @@ -1080,7 +1100,7 @@ mod tests { ws.send(Message::Text(r#"{"type":"ack","seq":9999999}"#.to_string())) .await .expect("send premature ack"); - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + crate::rt::sleep(std::time::Duration::from_millis(200)).await; // Delivery continues: each frame is covered by the watermark already // banked, so the writer never blocks on it. @@ -1115,7 +1135,7 @@ mod tests { )) .await .expect("send config"); - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + crate::rt::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","seq":1,"data":"one"}"#); let frame = next_frame(&mut ws).await; @@ -1210,7 +1230,7 @@ mod tests { .await .expect("send config"); } - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + crate::rt::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","seq":9,"data":"live"}"#); let frame = next_frame(&mut ws).await; @@ -1233,7 +1253,7 @@ mod tests { let mut ws = connect_client(server.port()).await; // Timed, because asserting only that both frames arrive passes at any // rate: a 1 fps default still delivers two, a second apart. - let started = tokio::time::Instant::now(); + let started = crate::rt::Instant::now(); server.broadcast_frame(r#"{"type":"frame","data":"one"}"#); let frame = next_frame(&mut ws).await; assert_eq!(frame.get("data").and_then(|v| v.as_str()), Some("one")); @@ -1269,7 +1289,7 @@ mod tests { ws.send(Message::Text(r#"{"type":"config","maxFps":1}"#.to_string())) .await .expect("send config"); - tokio::time::sleep(std::time::Duration::from_millis(200)).await; + crate::rt::sleep(std::time::Duration::from_millis(200)).await; // First frame is immediate and arms next_allowed = now + 1s (1 fps). server.broadcast_frame(r#"{"type":"frame","data":"a"}"#); @@ -1286,9 +1306,9 @@ mod tests { ws.send(Message::Text(r#"{"type":"config","maxFps":0}"#.to_string())) .await .expect("send config uncapped"); - tokio::time::sleep(std::time::Duration::from_millis(150)).await; + crate::rt::sleep(std::time::Duration::from_millis(150)).await; - let t0 = std::time::Instant::now(); + let t0 = crate::rt::Instant::now(); server.broadcast_frame(r#"{"type":"frame","data":"b"}"#); assert_eq!( next_frame(&mut ws) diff --git a/cli/src/native/tracing.rs b/cli/src/native/tracing.rs index 6737c7801..f351b389e 100644 --- a/cli/src/native/tracing.rs +++ b/cli/src/native/tracing.rs @@ -29,6 +29,12 @@ pub struct TracingState { pub events_dropped: bool, } +impl Default for TracingState { + fn default() -> Self { + Self::new() + } +} + impl TracingState { pub fn new() -> Self { Self { @@ -89,10 +95,10 @@ pub async fn trace_stop( let mut trace_events: Vec = Vec::new(); let mut stream_handle: Option = None; - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30); + let deadline = crate::rt::Instant::now() + crate::rt::Duration::from_secs(30); loop { - let result = tokio::time::timeout_at(deadline, rx.recv()).await; + let result = crate::rt::timeout_at(deadline, rx.recv()).await; match result { Ok(Ok(event)) => { @@ -236,10 +242,10 @@ pub async fn profiler_stop( let mut events: Vec = Vec::new(); let mut dropped = false; - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30); + let deadline = crate::rt::Instant::now() + crate::rt::Duration::from_secs(30); loop { - let result = tokio::time::timeout_at(deadline, rx.recv()).await; + let result = crate::rt::timeout_at(deadline, rx.recv()).await; match result { Ok(Ok(event)) => { diff --git a/cli/src/native/webdriver/appium.rs b/cli/src/native/webdriver/appium.rs index 3d85a5ef2..547705525 100644 --- a/cli/src/native/webdriver/appium.rs +++ b/cli/src/native/webdriver/appium.rs @@ -152,9 +152,15 @@ impl Drop for AppiumManager { } } +#[cfg(target_arch = "wasm32")] +async fn is_appium_running(_port: u16) -> bool { + false +} + +#[cfg(not(target_arch = "wasm32"))] async fn is_appium_running(port: u16) -> bool { let addr = format!("127.0.0.1:{}", port); - tokio::time::timeout( + crate::rt::timeout( Duration::from_secs(2), tokio::net::TcpStream::connect(&addr), ) @@ -190,15 +196,15 @@ fn launch_appium(port: u16) -> Result { } async fn wait_for_appium(port: u16, timeout_secs: u64) -> Result<(), String> { - let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs); + let deadline = crate::rt::Instant::now() + Duration::from_secs(timeout_secs); loop { - if tokio::time::Instant::now() > deadline { + if crate::rt::Instant::now() > deadline { return Err("Timeout waiting for Appium to start".to_string()); } if is_appium_running(port).await { return Ok(()); } - tokio::time::sleep(Duration::from_millis(500)).await; + crate::rt::sleep(Duration::from_millis(500)).await; } } diff --git a/cli/src/native/webdriver/client.rs b/cli/src/native/webdriver/client.rs index 003cf49db..65c21d9fb 100644 --- a/cli/src/native/webdriver/client.rs +++ b/cli/src/native/webdriver/client.rs @@ -1,4 +1,5 @@ use serde_json::{json, Value}; +#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; pub struct WebDriverClient { @@ -236,6 +237,12 @@ fn element_id_from_value( .ok_or("No element ID in response".to_string()) } +#[cfg(target_arch = "wasm32")] +async fn http_request(_method: &str, _url: &str, _body: Option<&Value>) -> Result { + Err("WebDriver HTTP transport is not supported on this platform".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result { let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?; let host = parsed.host_str().unwrap_or("127.0.0.1"); @@ -243,7 +250,7 @@ async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result, } +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)] #[serde(default, rename_all = "camelCase")] struct PluginManifest { @@ -119,6 +124,7 @@ struct PluginManifest { description: Option, } +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct PluginManifestResponse { @@ -198,6 +204,20 @@ async fn invoke_plugin( invoke_plugin_process(plugin, payload, timeout_secs, expose_plugin_error).await } +#[cfg(target_arch = "wasm32")] +async fn invoke_plugin_process( + plugin: &PluginConfig, + _payload: serde_json::Value, + _timeout_secs: u64, + _expose_plugin_error: bool, +) -> Result { + Err(format!( + "Plugin '{}' cannot run: plugin processes are not supported on this platform", + plugin.name + )) +} + +#[cfg(not(target_arch = "wasm32"))] async fn invoke_plugin_process( plugin: &PluginConfig, payload: serde_json::Value, @@ -226,7 +246,7 @@ async fn invoke_plugin_process( } drop(child.stdin.take()); - let output = tokio::time::timeout( + let output = crate::rt::timeout( std::time::Duration::from_secs(timeout_secs), child.wait_with_output(), ) @@ -426,12 +446,14 @@ pub async fn launch_mutations_from_plugins( Ok(mutations) } +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone, PartialEq, Eq)] enum PluginSourceKind { Npm, Github, } +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone, PartialEq, Eq)] struct PluginSource { kind: PluginSourceKind, @@ -440,12 +462,14 @@ struct PluginSource { source: String, } +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone, PartialEq, Eq)] enum PluginConfigScope { Project, Global, } +#[cfg(not(target_arch = "wasm32"))] struct PluginAddOptions { reference: String, name: Option, @@ -454,6 +478,7 @@ struct PluginAddOptions { no_manifest: bool, } +#[cfg(not(target_arch = "wasm32"))] fn parse_plugin_source(reference: &str) -> Result { let trimmed = reference.trim(); if trimmed.is_empty() { @@ -492,6 +517,7 @@ fn parse_plugin_source(reference: &str) -> Result { }) } +#[cfg(not(target_arch = "wasm32"))] fn parse_plugin_add_args(args: &[String]) -> Result { let mut reference = None; let mut name = None; @@ -554,6 +580,7 @@ fn parse_plugin_add_args(args: &[String]) -> Result { }) } +#[cfg(not(target_arch = "wasm32"))] async fn discover_plugin_manifest(plugin: &PluginConfig) -> Result { let payload = json!({ "protocol": PROTOCOL_VERSION, @@ -580,6 +607,7 @@ async fn discover_plugin_manifest(plugin: &PluginConfig) -> Result String { let raw = match source.kind { PluginSourceKind::Npm => package_name_without_version(&source.reference), @@ -596,6 +624,7 @@ fn derive_plugin_name(source: &PluginSource) -> String { .to_string() } +#[cfg(not(target_arch = "wasm32"))] fn package_name_without_version(reference: &str) -> String { if reference.starts_with('@') { let mut parts = reference.splitn(2, '/'); @@ -610,6 +639,7 @@ fn package_name_without_version(reference: &str) -> String { reference.split('@').next().unwrap_or(reference).to_string() } +#[cfg(not(target_arch = "wasm32"))] fn validate_plugin_name(name: &str) -> Result<(), String> { if name.trim().is_empty() { return Err("plugin name cannot be empty".to_string()); @@ -620,6 +650,7 @@ fn validate_plugin_name(name: &str) -> Result<(), String> { Ok(()) } +#[cfg(not(target_arch = "wasm32"))] fn config_path_for_scope(scope: &PluginConfigScope) -> Result { match scope { PluginConfigScope::Project => Ok(PathBuf::from("agent-browser.json")), @@ -629,6 +660,7 @@ fn config_path_for_scope(scope: &PluginConfigScope) -> Result { } } +#[cfg(not(target_arch = "wasm32"))] fn read_config_json(path: &Path) -> Result { if !path.exists() { return Ok(json!({})); @@ -643,6 +675,7 @@ fn read_config_json(path: &Path) -> Result { Ok(value) } +#[cfg(not(target_arch = "wasm32"))] fn write_config_json(path: &Path, value: &serde_json::Value) -> Result<(), String> { if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { fs::create_dir_all(parent).map_err(|e| { @@ -659,6 +692,7 @@ fn write_config_json(path: &Path, value: &serde_json::Value) -> Result<(), Strin .map_err(|e| format!("Failed to write config {}: {}", path.display(), e)) } +#[cfg(not(target_arch = "wasm32"))] fn upsert_plugin_config(path: &Path, plugin: &PluginConfig) -> Result<(), String> { let mut value = read_config_json(path)?; let obj = value @@ -684,6 +718,7 @@ fn upsert_plugin_config(path: &Path, plugin: &PluginConfig) -> Result<(), String write_config_json(path, &value) } +#[cfg(not(target_arch = "wasm32"))] fn build_plugin_config_from_add( source: &PluginSource, options: &PluginAddOptions, @@ -718,6 +753,7 @@ fn build_plugin_config_from_add( }) } +#[cfg(not(target_arch = "wasm32"))] fn print_plugin_added(plugin: &PluginConfig, path: &Path, json_output: bool) { if json_output { println!( @@ -740,6 +776,7 @@ fn print_plugin_added(plugin: &PluginConfig, path: &Path, json_output: bool) { println!("Capabilities: {}", plugin.capabilities.join(", ")); } +#[cfg(not(target_arch = "wasm32"))] fn add_plugin_command(args: &[String], json_output: bool) -> Result<(), String> { let options = parse_plugin_add_args(args)?; let source = parse_plugin_source(&options.reference)?; @@ -777,6 +814,7 @@ fn add_plugin_command(args: &[String], json_output: bool) -> Result<(), String> Ok(()) } +#[cfg(not(target_arch = "wasm32"))] pub fn run_plugin_command(args: &[String], plugins: &[PluginConfig], json_output: bool) { let sub = args.get(1).map(|s| s.as_str()).unwrap_or("list"); match sub { @@ -867,6 +905,7 @@ pub fn run_plugin_command(args: &[String], plugins: &[PluginConfig], json_output } } +#[cfg(not(target_arch = "wasm32"))] fn parse_run_payload(args: &[String]) -> Result { let mut payload = json!({}); let mut i = 4; @@ -889,6 +928,7 @@ fn parse_run_payload(args: &[String]) -> Result { Ok(payload) } +#[cfg(not(target_arch = "wasm32"))] fn is_core_plugin_entrypoint(entrypoint: &str) -> bool { matches!( entrypoint, @@ -902,6 +942,7 @@ fn is_core_plugin_entrypoint(entrypoint: &str) -> bool { ) } +#[cfg(not(target_arch = "wasm32"))] fn print_plugin_list(plugins: &[PluginConfig], json_output: bool) { if json_output { println!("{}", json!({ "plugins": plugins })); @@ -921,6 +962,7 @@ fn print_plugin_list(plugins: &[PluginConfig], json_output: bool) { } } +#[cfg(not(target_arch = "wasm32"))] fn print_plugin(plugin: &PluginConfig, json_output: bool) { if json_output { println!("{}", json!({ "plugin": plugin })); @@ -938,6 +980,7 @@ fn print_plugin(plugin: &PluginConfig, json_output: bool) { } } +#[cfg(not(target_arch = "wasm32"))] fn print_plugin_error(message: &str, json_output: bool) { if json_output { println!("{}", json!({ "success": false, "error": message })); @@ -1340,7 +1383,7 @@ printf '%s' '{"protocol":"agent-browser.plugin.v1","success":true,"data":{}}' .unwrap_err(); assert!(err.contains("timed out")); - tokio::time::sleep(std::time::Duration::from_millis(2_500)).await; + crate::rt::sleep(std::time::Duration::from_millis(2_500)).await; assert!(!marker_path.exists()); } } diff --git a/cli/src/read.rs b/cli/src/read.rs index 488c046e3..c60f25833 100644 --- a/cli/src/read.rs +++ b/cli/src/read.rs @@ -4,6 +4,7 @@ use reqwest::Client; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::error::Error; +#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use url::Url; @@ -189,20 +190,28 @@ struct LlmsLink { pub async fn run_read(raw_url: &str, options: ReadOptions) -> Result { let target = normalize_url(raw_url)?; check_allowed_url_for_options(&target, &options)?; - let redirect_allowed_domain_sets = allowed_domain_sets_for_options(&options); - let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { - if attempt.previous().len() > 10 { - attempt.error("too many redirects") - } else if let Err(e) = check_allowed_url_sets(attempt.url(), &redirect_allowed_domain_sets) - { - attempt.error(e) - } else { - attempt.follow() - } - }); + #[cfg(not(target_arch = "wasm32"))] + let client = { + let redirect_allowed_domain_sets = allowed_domain_sets_for_options(&options); + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + if attempt.previous().len() > 10 { + attempt.error("too many redirects") + } else if let Err(e) = + check_allowed_url_sets(attempt.url(), &redirect_allowed_domain_sets) + { + attempt.error(e) + } else { + attempt.follow() + } + }); + Client::builder() + .timeout(Duration::from_millis(options.timeout_ms)) + .redirect(redirect_policy) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))? + }; + #[cfg(target_arch = "wasm32")] let client = Client::builder() - .timeout(Duration::from_millis(options.timeout_ms)) - .redirect(redirect_policy) .build() .map_err(|e| format!("Failed to create HTTP client: {}", e))?; @@ -546,6 +555,7 @@ fn check_allowed_url(url: &Url, allowed_domains: &[String]) -> Result<(), String )) } +#[cfg(not(target_arch = "wasm32"))] fn allowed_domain_sets_for_options(options: &ReadOptions) -> Vec> { let mut sets = Vec::new(); if !options.allowed_domains.is_empty() { @@ -569,6 +579,7 @@ fn check_allowed_url_for_options(url: &Url, options: &ReadOptions) -> Result<(), Ok(()) } +#[cfg(not(target_arch = "wasm32"))] fn check_allowed_url_sets(url: &Url, allowed_domain_sets: &[Vec]) -> Result<(), String> { for domains in allowed_domain_sets { check_allowed_url(url, domains)?; @@ -1417,7 +1428,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let _ = stream.read(&mut buf).await.unwrap_or(0); @@ -1456,7 +1467,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let _ = stream.read(&mut buf).await.unwrap_or(0); @@ -1534,7 +1545,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1562,7 +1573,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { for expected_path in ["/docs/intro", "/docs/intro.md"] { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; @@ -1598,7 +1609,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { loop { let Ok((mut stream, _)) = listener.accept().await else { break; @@ -1643,7 +1654,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { for expected_path in [ "/docs/intro", "/docs/intro.md", @@ -1692,7 +1703,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { for expected_path in ["/docs/intro/llms.txt", "/docs/llms.txt"] { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; @@ -1738,7 +1749,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { for expected_path in ["/docs/intro/llms-full.txt", "/docs/llms-full.txt"] { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; @@ -1788,7 +1799,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1818,7 +1829,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1852,7 +1863,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1886,7 +1897,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - tokio::spawn(async move { + crate::rt::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); From 40c5b03eed099a14fe9b061ce20a87d81c90acc9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:53:36 +0000 Subject: [PATCH 05/11] chore: add Default impls to satisfy clippy on the new lib target Co-Authored-By: glavin@coframe.com --- cli/src/native/element.rs | 6 ++++++ cli/src/native/network.rs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/cli/src/native/element.rs b/cli/src/native/element.rs index 799814a5a..3a67f75ac 100644 --- a/cli/src/native/element.rs +++ b/cli/src/native/element.rs @@ -20,6 +20,12 @@ pub struct RefMap { next_ref: usize, } +impl Default for RefMap { + fn default() -> Self { + Self::new() + } +} + impl RefMap { pub fn new() -> Self { Self { diff --git a/cli/src/native/network.rs b/cli/src/native/network.rs index 98c2212a7..6a155817c 100644 --- a/cli/src/native/network.rs +++ b/cli/src/native/network.rs @@ -583,6 +583,12 @@ pub struct EventTracker { pub max_entries: usize, } +impl Default for EventTracker { + fn default() -> Self { + Self::new() + } +} + impl EventTracker { pub fn new() -> Self { Self { From efce6abee32c1bd7b0f2647e2b670f2b418731bc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:05:57 +0000 Subject: [PATCH 06/11] fix: make run_read native-only and track wasm task completion The wasm read path silently dropped the timeout and redirect-allowlist enforcement, so gate run_read to native targets and return an unsupported-platform error on wasm32. Track task completion with an AtomicBool so JoinHandle::is_finished reports finished tasks on wasm. Co-Authored-By: glavin@coframe.com --- cli/src/read.rs | 73 +++++++++++++++++++++++++------------------------ cli/src/rt.rs | 12 ++++++-- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/cli/src/read.rs b/cli/src/read.rs index c60f25833..c1ff9edc6 100644 --- a/cli/src/read.rs +++ b/cli/src/read.rs @@ -1,10 +1,12 @@ +// Most of this module supports the native-only run_read implementation below. +#![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))] + use futures_util::StreamExt; use reqwest::header::{ACCEPT, CONTENT_TYPE, USER_AGENT}; use reqwest::Client; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; use std::error::Error; -#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use url::Url; @@ -187,31 +189,32 @@ struct LlmsLink { url: Url, } +/// The read command requires a client-level timeout and a redirect policy +/// that re-validates every hop against the domain allowlist; reqwest's wasm +/// backend supports neither, so the command is native-only. +#[cfg(target_arch = "wasm32")] +pub async fn run_read(_raw_url: &str, _options: ReadOptions) -> Result { + Err("the read command is not supported on this platform".to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] pub async fn run_read(raw_url: &str, options: ReadOptions) -> Result { let target = normalize_url(raw_url)?; check_allowed_url_for_options(&target, &options)?; - #[cfg(not(target_arch = "wasm32"))] - let client = { - let redirect_allowed_domain_sets = allowed_domain_sets_for_options(&options); - let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { - if attempt.previous().len() > 10 { - attempt.error("too many redirects") - } else if let Err(e) = - check_allowed_url_sets(attempt.url(), &redirect_allowed_domain_sets) - { - attempt.error(e) - } else { - attempt.follow() - } - }); - Client::builder() - .timeout(Duration::from_millis(options.timeout_ms)) - .redirect(redirect_policy) - .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))? - }; - #[cfg(target_arch = "wasm32")] + let redirect_allowed_domain_sets = allowed_domain_sets_for_options(&options); + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { + if attempt.previous().len() > 10 { + attempt.error("too many redirects") + } else if let Err(e) = check_allowed_url_sets(attempt.url(), &redirect_allowed_domain_sets) + { + attempt.error(e) + } else { + attempt.follow() + } + }); let client = Client::builder() + .timeout(Duration::from_millis(options.timeout_ms)) + .redirect(redirect_policy) .build() .map_err(|e| format!("Failed to create HTTP client: {}", e))?; @@ -555,7 +558,6 @@ fn check_allowed_url(url: &Url, allowed_domains: &[String]) -> Result<(), String )) } -#[cfg(not(target_arch = "wasm32"))] fn allowed_domain_sets_for_options(options: &ReadOptions) -> Vec> { let mut sets = Vec::new(); if !options.allowed_domains.is_empty() { @@ -579,7 +581,6 @@ fn check_allowed_url_for_options(url: &Url, options: &ReadOptions) -> Result<(), Ok(()) } -#[cfg(not(target_arch = "wasm32"))] fn check_allowed_url_sets(url: &Url, allowed_domain_sets: &[Vec]) -> Result<(), String> { for domains in allowed_domain_sets { check_allowed_url(url, domains)?; @@ -1428,7 +1429,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let _ = stream.read(&mut buf).await.unwrap_or(0); @@ -1467,7 +1468,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let _ = stream.read(&mut buf).await.unwrap_or(0); @@ -1545,7 +1546,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1573,7 +1574,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { for expected_path in ["/docs/intro", "/docs/intro.md"] { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; @@ -1609,7 +1610,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { loop { let Ok((mut stream, _)) = listener.accept().await else { break; @@ -1654,7 +1655,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { for expected_path in [ "/docs/intro", "/docs/intro.md", @@ -1703,7 +1704,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { for expected_path in ["/docs/intro/llms.txt", "/docs/llms.txt"] { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; @@ -1749,7 +1750,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { for expected_path in ["/docs/intro/llms-full.txt", "/docs/llms-full.txt"] { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; @@ -1799,7 +1800,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1829,7 +1830,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1863,7 +1864,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}/docs/intro", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); @@ -1897,7 +1898,7 @@ Inline [Authentication](/inline-auth) should not become a TOC item. let addr = listener.local_addr().unwrap(); let base = format!("http://{}", addr); - crate::rt::spawn(async move { + tokio::spawn(async move { let (mut stream, _) = listener.accept().await.unwrap(); let mut buf = [0_u8; 2048]; let n = stream.read(&mut buf).await.unwrap_or(0); diff --git a/cli/src/rt.rs b/cli/src/rt.rs index d3c57298a..98037a2cb 100644 --- a/cli/src/rt.rs +++ b/cli/src/rt.rs @@ -38,6 +38,8 @@ mod imp { use futures_util::future::{AbortHandle, Abortable}; use std::future::Future; use std::ops::{Add, AddAssign, Sub, SubAssign}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; /// Monotonic-enough instant backed by `Date.now()`. #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] @@ -232,6 +234,7 @@ mod imp { pub struct JoinHandle { receiver: tokio::sync::oneshot::Receiver, abort_handle: AbortHandle, + finished: Arc, } impl JoinHandle { @@ -240,7 +243,7 @@ mod imp { } pub fn is_finished(&self) -> bool { - self.abort_handle.is_aborted() + self.finished.load(Ordering::Acquire) || self.abort_handle.is_aborted() } } @@ -266,14 +269,19 @@ mod imp { let (sender, receiver) = tokio::sync::oneshot::channel(); let (abort_handle, abort_registration) = AbortHandle::new_pair(); let task = Abortable::new(future, abort_registration); + let finished = Arc::new(AtomicBool::new(false)); + let finished_flag = finished.clone(); wasm_bindgen_futures::spawn_local(async move { - if let Ok(output) = task.await { + let result = task.await; + finished_flag.store(true, Ordering::Release); + if let Ok(output) = result { let _ = sender.send(output); } }); JoinHandle { receiver, abort_handle, + finished, } } From 7850bbd2bc58d5f12aa6e2e752f879efa3d17b1b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:25:42 +0000 Subject: [PATCH 07/11] refactor: keep tokio in test modules and native-only code, use rt only where wasm compiles it Co-Authored-By: glavin@coframe.com --- cli/src/install.rs | 14 +++---- cli/src/native/actions.rs | 12 +++--- cli/src/native/browser.rs | 63 +++++++++++++++--------------- cli/src/native/cdp/chrome.rs | 22 +++++------ cli/src/native/cdp/discovery.rs | 12 +++--- cli/src/native/cdp/lightpanda.rs | 6 +-- cli/src/native/inspect_server.rs | 10 ++--- cli/src/native/recording.rs | 6 +-- cli/src/native/stream/mod.rs | 40 +++++++++---------- cli/src/native/webdriver/appium.rs | 2 +- cli/src/native/webdriver/client.rs | 2 +- cli/src/plugins.rs | 4 +- 12 files changed, 97 insertions(+), 96 deletions(-) diff --git a/cli/src/install.rs b/cli/src/install.rs index 9b28adb12..133b45898 100644 --- a/cli/src/install.rs +++ b/cli/src/install.rs @@ -269,7 +269,7 @@ async fn download_bytes(url: &str) -> Result, String> { attempt + 1, max_retries ); - crate::rt::sleep(std::time::Duration::from_secs(1 << attempt)).await; + tokio::time::sleep(std::time::Duration::from_secs(1 << attempt)).await; } let resp = match client.get(url).send().await { @@ -929,7 +929,7 @@ mod tests { let body = b"fake-zip-content"; let resp = http_response(200, "OK", body); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { accept_once(&listener, &resp).await; }); @@ -946,7 +946,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let resp = http_response(404, "Not Found", b"not found"); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { accept_once(&listener, &resp).await; }); @@ -967,7 +967,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { // First two attempts: 500 let r500 = http_response(500, "Internal Server Error", b"error"); accept_once(&listener, &r500).await; @@ -993,7 +993,7 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { let r500 = http_response(500, "Internal Server Error", b"error"); // All 3 attempts get 500 accept_once(&listener, &r500).await; @@ -1019,7 +1019,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let resp = http_response(403, "Forbidden", b"forbidden"); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { // Only one request should arrive (no retries for 4xx) accept_once(&listener, &resp).await; }); @@ -1037,7 +1037,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let resp = http_response(200, "OK", b"ok"); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { let req = accept_with_ua_check(&listener, &resp).await; req }); diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index baee38a39..dce4af822 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -11587,10 +11587,10 @@ mod tests { async fn start_webdriver_response_server( responses: Vec<(&'static str, Value)>, - ) -> (u16, crate::rt::JoinHandle) { + ) -> (u16, tokio::task::JoinHandle) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let handle = crate::rt::spawn(async move { + let handle = tokio::spawn(async move { let mut handled = 0; for (expected_path, body) in responses { let (mut stream, _) = listener.accept().await.unwrap(); @@ -11697,10 +11697,10 @@ mod tests { fn test_autosave_waits_for_quiet_period_after_command() { let mut state = DaemonState::new(); - state.last_command_finished = Some(crate::rt::Instant::now()); + state.last_command_finished = Some(std::time::Instant::now()); assert!(!autosave_due(&state, 30_000)); - state.last_command_finished = crate::rt::Instant::now().checked_sub( + state.last_command_finished = std::time::Instant::now().checked_sub( std::time::Duration::from_millis(AUTOSAVE_QUIET_PERIOD_MS + 1_000), ); assert!(state.last_command_finished.is_some()); @@ -11711,11 +11711,11 @@ mod tests { fn test_autosave_enforces_min_interval_between_attempts() { let mut state = DaemonState::new(); - state.last_autosave_attempt = Some(crate::rt::Instant::now()); + state.last_autosave_attempt = Some(std::time::Instant::now()); assert!(!autosave_due(&state, 30_000)); state.last_autosave_attempt = - crate::rt::Instant::now().checked_sub(std::time::Duration::from_secs(31)); + std::time::Instant::now().checked_sub(std::time::Duration::from_secs(31)); assert!(state.last_autosave_attempt.is_some()); assert!(autosave_due(&state, 30_000)); } diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index b88f8462c..7f0ba9003 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -2068,7 +2068,7 @@ async fn resolve_cdp_url(input: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::rt::sleep; + use tokio::time::sleep; #[test] fn test_format_tab_id() { @@ -2383,7 +2383,7 @@ mod tests { #[tokio::test] async fn test_run_with_lightpanda_deadline_enforces_timeout() { let deadline = Instant::now() + Duration::from_millis(25); - let err = crate::rt::timeout( + let err = tokio::time::timeout( Duration::from_secs(1), run_with_lightpanda_deadline( deadline, @@ -2475,8 +2475,8 @@ mod tests { let (tx, mut rx) = broadcast::channel::(16); let session = "s1"; - let start = crate::rt::Instant::now(); - let result = crate::rt::timeout( + let start = tokio::time::Instant::now(); + let result = tokio::time::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) @@ -2502,7 +2502,7 @@ mod tests { let session = "s1"; let _keep_alive = tx.clone(); - crate::rt::spawn(async move { + tokio::spawn(async move { sleep(Duration::from_millis(50)).await; let _ = tx.send(cdp_event( "Network.requestWillBeSent", @@ -2517,8 +2517,8 @@ mod tests { )); }); - let start = crate::rt::Instant::now(); - let result = crate::rt::timeout( + let start = tokio::time::Instant::now(); + let result = tokio::time::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) @@ -2541,7 +2541,7 @@ mod tests { let session = "s1"; let _keep_alive = tx.clone(); - crate::rt::spawn(async move { + tokio::spawn(async move { sleep(Duration::from_millis(50)).await; let _ = tx.send(cdp_event( "Network.requestWillBeSent", @@ -2569,8 +2569,8 @@ mod tests { )); }); - let start = crate::rt::Instant::now(); - let result = crate::rt::timeout( + let start = tokio::time::Instant::now(); + let result = tokio::time::timeout( Duration::from_secs(5), poll_network_idle(session, &mut rx, Duration::from_secs(5)), ) @@ -2595,7 +2595,7 @@ mod tests { let session = "s1"; // Keep sending requests so idle is never reached - crate::rt::spawn(async move { + tokio::spawn(async move { for i in 0u64.. { let _ = tx.send(cdp_event( "Network.requestWillBeSent", @@ -2631,7 +2631,7 @@ mod tests { listener.local_addr().unwrap().port() ); - crate::rt::spawn(async move { + tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -2720,7 +2720,7 @@ mod tests { assert_eq!(mgr.pages.len(), 2); assert_eq!(mgr.active_page_index, 0); - let result = crate::rt::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) + let result = tokio::time::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang on a discarded tab") .expect("switching to a discarded tab should revive it"); @@ -2743,7 +2743,7 @@ mod tests { let url = start_mock_cdp_browser_with_discarded_tab(false).await; let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); - let err = crate::rt::timeout(Duration::from_secs(25), mgr.tab_switch(1, None)) + let err = tokio::time::timeout(Duration::from_secs(25), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang on a dead tab") .expect_err("switching to an unrevivable tab should fail"); @@ -2778,7 +2778,7 @@ mod tests { listener.local_addr().unwrap().port() ); - crate::rt::spawn(async move { + tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -2842,7 +2842,7 @@ mod tests { let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); assert_eq!(mgr.pages.len(), 2); - let result = crate::rt::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) + let result = tokio::time::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang") .expect("switching to a responsive tab should succeed"); @@ -2865,13 +2865,13 @@ mod tests { let url = start_mock_cdp_browser_with_discarded_tab(true).await; let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); - let _ = crate::rt::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) + let _ = tokio::time::timeout(Duration::from_secs(20), mgr.tab_switch(1, None)) .await .expect("tab_switch must not hang") .expect("switching to a discarded tab should revive it"); // Cleanup runs on the guard's drop via a spawned task; let it settle. - crate::rt::sleep(Duration::from_millis(200)).await; + tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!( mgr.client.pending_len().await, @@ -2901,7 +2901,7 @@ mod tests { listener.local_addr().unwrap().port() ); - crate::rt::spawn(async move { + tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -2970,7 +2970,7 @@ mod tests { let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); assert_eq!(mgr.pages.len(), 2); - let result = crate::rt::timeout( + let result = tokio::time::timeout( Duration::from_secs(20), mgr.tab_switch(1, Some("S-T-BLOCKED")), ) @@ -3006,7 +3006,7 @@ mod tests { assert_eq!(mgr.active_page_index, 0); // Close the live active tab; the discarded tab becomes the successor. - let result = crate::rt::timeout(Duration::from_secs(20), mgr.tab_close(Some(0), None)) + let result = tokio::time::timeout(Duration::from_secs(20), mgr.tab_close(Some(0), None)) .await .expect("tab_close must not hang on a discarded successor") .expect("closing a tab with a discarded successor should succeed"); @@ -3030,7 +3030,7 @@ mod tests { let mut mgr = BrowserManager::connect_cdp(&url).await.expect("connect"); assert_eq!(mgr.pages.len(), 2); - let result = crate::rt::timeout(Duration::from_secs(25), mgr.tab_close(Some(0), None)) + let result = tokio::time::timeout(Duration::from_secs(25), mgr.tab_close(Some(0), None)) .await .expect("tab_close must not hang") .expect("a committed close must report success even if the successor is dead"); @@ -3092,7 +3092,7 @@ mod tests { .map(|(id, _)| format!("S-{}", id)) .collect(); - crate::rt::spawn(async move { + tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let ws = tokio_tungstenite::accept_async(stream).await.unwrap(); let (mut tx, mut rx) = ws.split(); @@ -3165,7 +3165,7 @@ mod tests { let (url, activations) = start_mock_cdp_connect(vec![("DISCARDED", false), ("ALIVE", true)], false).await; - let mgr = crate::rt::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) + let mgr = tokio::time::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) .await .expect("connect must not hang on a discarded first target") .expect("connect should succeed by selecting the live tab"); @@ -3196,7 +3196,7 @@ mod tests { let targets: Vec<(&'static str, bool)> = ids.iter().map(|id| (*id, true)).collect(); let (url, activations) = start_mock_cdp_connect(targets, false).await; - let mgr = crate::rt::timeout(Duration::from_secs(5), BrowserManager::connect_cdp(&url)) + let mgr = tokio::time::timeout(Duration::from_secs(5), BrowserManager::connect_cdp(&url)) .await .expect("connect with many live tabs must stay fast") .expect("connect should succeed"); @@ -3220,7 +3220,7 @@ mod tests { async fn test_connect_all_discarded_revives_first_tab() { let (url, activations) = start_mock_cdp_connect(vec![("ONLY", false)], true).await; - let mgr = crate::rt::timeout(Duration::from_secs(20), BrowserManager::connect_cdp(&url)) + let mgr = tokio::time::timeout(Duration::from_secs(20), BrowserManager::connect_cdp(&url)) .await .expect("connect must not hang when all tabs are discarded") .expect("connect should succeed after reviving the only tab"); @@ -3238,9 +3238,10 @@ mod tests { async fn test_connect_all_discarded_unrevivable_fails_fast() { let (url, _activations) = start_mock_cdp_connect(vec![("ONLY", false)], false).await; - let result = crate::rt::timeout(Duration::from_secs(30), BrowserManager::connect_cdp(&url)) - .await - .expect("connect must not hang on an unrevivable discarded tab"); + let result = + tokio::time::timeout(Duration::from_secs(30), BrowserManager::connect_cdp(&url)) + .await + .expect("connect must not hang on an unrevivable discarded tab"); match result { Ok(_) => panic!("connect should fail when no tab can be made live"), @@ -3264,7 +3265,7 @@ mod tests { ) .await; - let mgr = crate::rt::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) + let mgr = tokio::time::timeout(Duration::from_secs(15), BrowserManager::connect_cdp(&url)) .await .expect("connect must not hang") .expect("connect should select the live tab"); @@ -3274,7 +3275,7 @@ mod tests { ); // Drop-guard cleanup runs on a spawned task; let it settle. - crate::rt::sleep(Duration::from_millis(300)).await; + tokio::time::sleep(Duration::from_millis(300)).await; assert_eq!( mgr.client.pending_len().await, 0, diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 5294460fe..101f164af 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -49,7 +49,7 @@ impl ChromeProcess { /// falling back to kill() if it doesn't exit within the timeout. /// This allows Chrome to flush cookies and other state to the user-data-dir. pub fn wait_or_kill(&mut self, timeout: Duration) { - let start = crate::rt::Instant::now(); + let start = std::time::Instant::now(); let poll_interval = Duration::from_millis(50); while start.elapsed() < timeout { @@ -248,7 +248,7 @@ fn maybe_start_xvfb(options: &LaunchOptions) -> Option { libc::fcntl(fds[0], libc::F_SETFL, flags | libc::O_NONBLOCK); } - let deadline = crate::rt::Instant::now() + Duration::from_secs(5); + let deadline = std::time::Instant::now() + Duration::from_secs(5); let mut buf: Vec = Vec::new(); loop { let mut chunk = [0u8; 16]; @@ -271,7 +271,7 @@ fn maybe_start_xvfb(options: &LaunchOptions) -> Option { } } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - if crate::rt::Instant::now() >= deadline { + if std::time::Instant::now() >= deadline { break; } std::thread::sleep(Duration::from_millis(50)); @@ -682,7 +682,7 @@ fn try_launch_chrome(chrome_path: &Path, options: &LaunchOptions) -> Result Result Result { let poll_interval = Duration::from_millis(50); - while crate::rt::Instant::now() <= deadline { + while std::time::Instant::now() <= deadline { if let Ok(Some(status)) = child.try_wait() { // Chrome exited before writing DevToolsActivePort -- report the // exit code so the caller can surface it alongside stderr output. @@ -764,13 +764,13 @@ fn wait_for_devtools_active_port( fn wait_for_ws_url_until( reader: BufReader, - deadline: crate::rt::Instant, + deadline: std::time::Instant, ) -> Result { let prefix = "DevTools listening on "; let mut stderr_lines: Vec = Vec::new(); for line in reader.lines() { - if crate::rt::Instant::now() > deadline { + if std::time::Instant::now() > deadline { return Err(chrome_launch_error( "Timeout waiting for Chrome DevTools URL", &stderr_lines, @@ -1013,7 +1013,7 @@ async fn verify_ws_endpoint(ws_url: &str) -> bool { use tokio_tungstenite::tungstenite::Message; let timeout = Duration::from_secs(2); - let result = crate::rt::timeout(timeout, async { + let result = tokio::time::timeout(timeout, async { let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?; let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#; ws.send(Message::Text(cmd.into())).await.ok()?; @@ -2436,7 +2436,7 @@ mod tests { let port = listener.local_addr().unwrap().port(); let ws_path = "/devtools/browser/test-uuid-1234".to_string(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { // accept: verify_ws_endpoint() WebSocket handshake let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); @@ -2473,7 +2473,7 @@ mod tests { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { // 1st accept: verify_ws_endpoint() ws_path probe — reject (just close) let (s1, _) = listener.accept().await.unwrap(); drop(s1); diff --git a/cli/src/native/cdp/discovery.rs b/cli/src/native/cdp/discovery.rs index b16afecb8..4e6c59d78 100644 --- a/cli/src/native/cdp/discovery.rs +++ b/cli/src/native/cdp/discovery.rs @@ -172,7 +172,7 @@ async fn discover_cdp_ws(_host: &str, _port: u16, _timeout: Duration) -> Result< async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result { let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port); - crate::rt::timeout(timeout, async { + tokio::time::timeout(timeout, async { let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url) .await .map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?; @@ -242,7 +242,7 @@ mod tests { async fn discovers_ws_url_from_json_version() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { accept_http( &listener, &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#), @@ -259,7 +259,7 @@ mod tests { async fn returns_error_when_version_returns_invalid_json() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { accept_http(&listener, &http_200("not-json")).await; // /json/list and ws fallback both fail (server closes) }); @@ -273,7 +273,7 @@ mod tests { async fn falls_back_to_json_list_on_version_404() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { accept_http(&listener, HTTP_404).await; accept_http( &listener, @@ -291,7 +291,7 @@ mod tests { async fn falls_back_to_ws_when_http_returns_404() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { // /json/version -> 404, /json/list -> 404 accept_http(&listener, HTTP_404).await; accept_http(&listener, HTTP_404).await; @@ -378,7 +378,7 @@ mod tests { async fn discover_preserves_query_params() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); - let server = crate::rt::spawn(async move { + let server = tokio::spawn(async move { accept_http( &listener, &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#), diff --git a/cli/src/native/cdp/lightpanda.rs b/cli/src/native/cdp/lightpanda.rs index 0d5c57d4f..d4042fe57 100644 --- a/cli/src/native/cdp/lightpanda.rs +++ b/cli/src/native/cdp/lightpanda.rs @@ -331,7 +331,7 @@ mod tests { } async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) { - crate::rt::sleep(Duration::from_millis(delay_ms)).await; + tokio::time::sleep(Duration::from_millis(delay_ms)).await; let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap(); let (mut socket, _) = listener.accept().await.unwrap(); let mut buf = [0u8; 1024]; @@ -348,7 +348,7 @@ mod tests { #[tokio::test] async fn waits_for_ready_without_logs() { let port = unused_port(); - crate::rt::spawn(serve_json_version_once_after_delay( + tokio::spawn(serve_json_version_once_after_delay( port, 150, r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#, @@ -407,7 +407,7 @@ mod tests { let timeout = Duration::from_millis(300); let (logs, _drainers) = start_log_drainers(&mut child).unwrap(); - let err = crate::rt::timeout( + let err = tokio::time::timeout( Duration::from_secs(2), wait_for_lightpanda_ready(&mut child, port, &logs, timeout), ) diff --git a/cli/src/native/inspect_server.rs b/cli/src/native/inspect_server.rs index 1384f7a47..b700c0f99 100644 --- a/cli/src/native/inspect_server.rs +++ b/cli/src/native/inspect_server.rs @@ -75,7 +75,7 @@ impl InspectServer { let proxy = Arc::new(proxy_handle); - let handle = crate::rt::spawn(accept_loop( + let handle = tokio::spawn(accept_loop( listener, proxy, target_id, @@ -116,7 +116,7 @@ async fn accept_loop( let tid = target_id.clone(); let chp = chrome_host_port.clone(); - crate::rt::spawn(async move { + tokio::spawn(async move { if let Err(e) = handle_connection(stream, proxy, tid, chp, proxy_port).await { let _ = writeln!(std::io::stderr(), "[inspect] connection error: {}", e); } @@ -234,7 +234,7 @@ async fn handle_ws_proxy( .map_err(|e| format!("Failed to send attachToTarget: {}", e))?; // Wait for the attachToTarget response to extract the session ID - let session_id = crate::rt::timeout(std::time::Duration::from_secs(5), async { + let session_id = tokio::time::timeout(std::time::Duration::from_secs(5), async { while let Ok(raw_msg) = raw_rx.recv().await { if let Ok(val) = serde_json::from_str::(&raw_msg.text) { if val.get("id").and_then(|v| v.as_i64()) == Some(attach_id) { @@ -263,7 +263,7 @@ async fn handle_ws_proxy( let session_id_clone = session_id.clone(); // Chrome -> DevTools: forward messages matching our session, strip sessionId - let mut chrome_to_devtools = crate::rt::spawn(async move { + let mut chrome_to_devtools = tokio::spawn(async move { loop { let raw_msg = match raw_rx.recv().await { Ok(msg) => msg, @@ -294,7 +294,7 @@ async fn handle_ws_proxy( // DevTools -> Chrome: inject sessionId and forward let proxy_for_send = proxy.clone(); let session_id_for_send = session_id.clone(); - let mut devtools_to_chrome = crate::rt::spawn(async move { + let mut devtools_to_chrome = tokio::spawn(async move { while let Some(Ok(msg)) = ws_rx.next().await { let text = match msg { Message::Text(t) => t, diff --git a/cli/src/native/recording.rs b/cli/src/native/recording.rs index 4a38d53f0..96af67d48 100644 --- a/cli/src/native/recording.rs +++ b/cli/src/native/recording.rs @@ -156,7 +156,7 @@ pub fn spawn_recording_task( shared_count: Arc, cancel_rx: oneshot::Receiver<()>, ) -> crate::rt::JoinHandle> { - crate::rt::spawn(async move { + tokio::spawn(async move { let mut cancel_rx = std::pin::pin!(cancel_rx); let mut ffmpeg = build_ffmpeg_command(&output_path).spawn().map_err(|e| { @@ -171,8 +171,8 @@ pub fn spawn_recording_task( .take() .ok_or_else(|| "Failed to open ffmpeg stdin".to_string())?; - let mut interval = crate::rt::interval(Duration::from_millis(CAPTURE_INTERVAL_MS)); - interval.set_missed_tick_behavior(crate::rt::MissedTickBehavior::Skip); + let mut interval = tokio::time::interval(Duration::from_millis(CAPTURE_INTERVAL_MS)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let params = CaptureScreenshotParams { format: Some("jpeg".to_string()), diff --git a/cli/src/native/stream/mod.rs b/cli/src/native/stream/mod.rs index 8269a86ca..017246dc2 100644 --- a/cli/src/native/stream/mod.rs +++ b/cli/src/native/stream/mod.rs @@ -378,7 +378,7 @@ impl StreamServer { let accept_shutdown_rx = shutdown_rx.clone(); let session_name_clone = session_id.clone(); let frame_watch_accept = frame_watch_rx.clone(); - let accept_task = crate::rt::spawn(async move { + let accept_task = tokio::spawn(async move { websocket::accept_loop( listener, frame_tx_clone, @@ -413,7 +413,7 @@ impl StreamServer { let recording_bg = recording.clone(); let frame_watch_bg = frame_watch_tx.clone(); let screencast_cfg_bg = screencast_config.clone(); - let cdp_task = crate::rt::spawn(async move { + let cdp_task = tokio::spawn(async move { cdp_loop::cdp_event_loop( frame_tx_bg, frame_watch_bg, @@ -758,7 +758,7 @@ mod tests { /// broken server fails instead of hanging. async fn next_frame(ws: &mut WsClient) -> Value { loop { - let msg = crate::rt::timeout(std::time::Duration::from_secs(5), ws.next()) + let msg = tokio::time::timeout(std::time::Duration::from_secs(5), ws.next()) .await .expect("timed out waiting for frame") .expect("stream ended") @@ -856,14 +856,14 @@ mod tests { r#"{{"type":"frame","seq":{},"data":"{}"}}"#, seq, big )); - crate::rt::sleep(std::time::Duration::from_millis(10)).await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; } assert!( Arc::strong_count(&idle) > connected_floor, "a connected client should hold the activity clock" ); - let teardown = crate::rt::Instant::now(); + let teardown = tokio::time::Instant::now(); server.shutdown().await; let took = teardown.elapsed(); assert!( @@ -879,7 +879,7 @@ mod tests { if Arc::strong_count(&idle) == 1 { break; } - crate::rt::sleep(std::time::Duration::from_millis(20)).await; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; } assert_eq!( Arc::strong_count(&idle), @@ -931,9 +931,9 @@ mod tests { .expect("server start"); let mut ws = connect_client_to(server.port(), "/?maxFps=1").await; - crate::rt::sleep(std::time::Duration::from_millis(150)).await; + tokio::time::sleep(std::time::Duration::from_millis(150)).await; - let sent_at = crate::rt::Instant::now(); + let sent_at = tokio::time::Instant::now(); server.broadcast_frame(r#"{"type":"frame","seq":1,"data":"first"}"#); let frame = next_frame(&mut ws).await; assert_eq!(frame.get("seq").and_then(|v| v.as_u64()), Some(1)); @@ -963,7 +963,7 @@ mod tests { .await .expect("send config"); // Let the reader task apply the config before frames start flowing. - crate::rt::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","data":"first"}"#); let frame = next_frame(&mut ws).await; @@ -981,13 +981,13 @@ mod tests { /// Assert no frame arrives within `ms`. A plain `next_frame` would pass on /// the very behavior these tests forbid. async fn expect_no_frame(ws: &mut WsClient, ms: u64) { - let deadline = crate::rt::Instant::now() + std::time::Duration::from_millis(ms); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(ms); loop { - let remaining = deadline.saturating_duration_since(crate::rt::Instant::now()); + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { return; } - match crate::rt::timeout(remaining, ws.next()).await { + match tokio::time::timeout(remaining, ws.next()).await { Err(_) => return, Ok(Some(Ok(Message::Text(text)))) => { let parsed: Value = serde_json::from_str(&text).expect("valid json"); @@ -1022,7 +1022,7 @@ mod tests { )) .await .expect("send config"); - crate::rt::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","seq":1,"data":"one"}"#); let frame = next_frame(&mut ws).await; @@ -1100,7 +1100,7 @@ mod tests { ws.send(Message::Text(r#"{"type":"ack","seq":9999999}"#.to_string())) .await .expect("send premature ack"); - crate::rt::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; // Delivery continues: each frame is covered by the watermark already // banked, so the writer never blocks on it. @@ -1135,7 +1135,7 @@ mod tests { )) .await .expect("send config"); - crate::rt::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","seq":1,"data":"one"}"#); let frame = next_frame(&mut ws).await; @@ -1230,7 +1230,7 @@ mod tests { .await .expect("send config"); } - crate::rt::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; server.broadcast_frame(r#"{"type":"frame","seq":9,"data":"live"}"#); let frame = next_frame(&mut ws).await; @@ -1253,7 +1253,7 @@ mod tests { let mut ws = connect_client(server.port()).await; // Timed, because asserting only that both frames arrive passes at any // rate: a 1 fps default still delivers two, a second apart. - let started = crate::rt::Instant::now(); + let started = tokio::time::Instant::now(); server.broadcast_frame(r#"{"type":"frame","data":"one"}"#); let frame = next_frame(&mut ws).await; assert_eq!(frame.get("data").and_then(|v| v.as_str()), Some("one")); @@ -1289,7 +1289,7 @@ mod tests { ws.send(Message::Text(r#"{"type":"config","maxFps":1}"#.to_string())) .await .expect("send config"); - crate::rt::sleep(std::time::Duration::from_millis(200)).await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; // First frame is immediate and arms next_allowed = now + 1s (1 fps). server.broadcast_frame(r#"{"type":"frame","data":"a"}"#); @@ -1306,9 +1306,9 @@ mod tests { ws.send(Message::Text(r#"{"type":"config","maxFps":0}"#.to_string())) .await .expect("send config uncapped"); - crate::rt::sleep(std::time::Duration::from_millis(150)).await; + tokio::time::sleep(std::time::Duration::from_millis(150)).await; - let t0 = crate::rt::Instant::now(); + let t0 = std::time::Instant::now(); server.broadcast_frame(r#"{"type":"frame","data":"b"}"#); assert_eq!( next_frame(&mut ws) diff --git a/cli/src/native/webdriver/appium.rs b/cli/src/native/webdriver/appium.rs index 547705525..555f8427d 100644 --- a/cli/src/native/webdriver/appium.rs +++ b/cli/src/native/webdriver/appium.rs @@ -160,7 +160,7 @@ async fn is_appium_running(_port: u16) -> bool { #[cfg(not(target_arch = "wasm32"))] async fn is_appium_running(port: u16) -> bool { let addr = format!("127.0.0.1:{}", port); - crate::rt::timeout( + tokio::time::timeout( Duration::from_secs(2), tokio::net::TcpStream::connect(&addr), ) diff --git a/cli/src/native/webdriver/client.rs b/cli/src/native/webdriver/client.rs index 65c21d9fb..47c8be443 100644 --- a/cli/src/native/webdriver/client.rs +++ b/cli/src/native/webdriver/client.rs @@ -250,7 +250,7 @@ async fn http_request(method: &str, url: &str, body: Option<&Value>) -> Result Date: Thu, 6 Aug 2026 22:20:25 +0000 Subject: [PATCH 08/11] feat: add BrowserManager::from_client for injected CDP transports Co-Authored-By: glavin@coframe.com --- cli/src/native/browser.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cli/src/native/browser.rs b/cli/src/native/browser.rs index 7f0ba9003..977d2b55b 100644 --- a/cli/src/native/browser.rs +++ b/cli/src/native/browser.rs @@ -527,6 +527,17 @@ impl BrowserManager { ) -> Result { let ws_url = resolve_cdp_url(url).await?; let client = Arc::new(CdpClient::connect_with_headers(&ws_url, headers).await?); + Self::from_client(client, ws_url, direct_page).await + } + + /// Build a manager around an already-connected CDP client, e.g. one + /// created via [`CdpClient::from_transport`] on hosts that supply their + /// own WebSocket. `ws_url` is informational (reported by `cdp_url`). + pub async fn from_client( + client: Arc, + ws_url: String, + direct_page: bool, + ) -> Result { let mut manager = Self { client, browser_process: None, From 3c715a982560b31461a5b24e5eac71456ec2935b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:20:25 +0000 Subject: [PATCH 09/11] feat: buffer CLI output behind a capture seam for library hosts Co-Authored-By: glavin@coframe.com --- cli/src/output.rs | 352 +++++++++++++++++++++++++++------------------- 1 file changed, 211 insertions(+), 141 deletions(-) diff --git a/cli/src/output.rs b/cli/src/output.rs index 05ce09ff0..65f8097cd 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -1,8 +1,74 @@ -use std::sync::OnceLock; +use std::sync::{Mutex, OnceLock}; use crate::color; use crate::connection::Response; +/// Buffered stdout/stderr text collected while a capture is active. Library +/// hosts without a usable process stdout (e.g. wasm) wrap command dispatch in +/// [`begin_capture`]/[`end_capture`] to receive the exact text the CLI would +/// have printed. +#[derive(Debug, Default, Clone)] +pub struct CapturedOutput { + pub stdout: String, + pub stderr: String, +} + +static OUTPUT_CAPTURE: Mutex> = Mutex::new(None); + +/// Start buffering this module's output instead of writing it to the process +/// streams. Captures are process-global; nested captures are not supported. +pub fn begin_capture() { + *OUTPUT_CAPTURE.lock().unwrap() = Some(CapturedOutput::default()); +} + +/// Stop capturing and return everything buffered since [`begin_capture`]. +/// Returns an empty capture if no capture was active. +pub fn end_capture() -> CapturedOutput { + OUTPUT_CAPTURE.lock().unwrap().take().unwrap_or_default() +} + +#[doc(hidden)] +pub fn emit_stdout(text: std::fmt::Arguments<'_>, newline: bool) { + let mut guard = OUTPUT_CAPTURE.lock().unwrap(); + if let Some(capture) = guard.as_mut() { + capture.stdout.push_str(&text.to_string()); + if newline { + capture.stdout.push('\n'); + } + } else if newline { + println!("{}", text); + } else { + print!("{}", text); + } +} + +#[doc(hidden)] +pub fn emit_stderr(text: std::fmt::Arguments<'_>) { + let mut guard = OUTPUT_CAPTURE.lock().unwrap(); + if let Some(capture) = guard.as_mut() { + capture.stderr.push_str(&text.to_string()); + capture.stderr.push('\n'); + } else { + eprintln!("{}", text); + } +} + +/// Like `println!` but respects an active output capture. +macro_rules! outln { + () => { $crate::output::emit_stdout(format_args!(""), true) }; + ($($arg:tt)*) => { $crate::output::emit_stdout(format_args!($($arg)*), true) }; +} + +/// Like `print!` but respects an active output capture. +macro_rules! out { + ($($arg:tt)*) => { $crate::output::emit_stdout(format_args!($($arg)*), false) }; +} + +/// Like `eprintln!` but respects an active output capture. +macro_rules! errln { + ($($arg:tt)*) => { $crate::output::emit_stderr(format_args!($($arg)*)) }; +} + static BOUNDARY_NONCE: OnceLock = OnceLock::new(); /// Per-process nonce for content boundary markers. Uses a CSPRNG (getrandom) so @@ -77,9 +143,9 @@ fn format_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOpti fn print_with_boundaries(content: &str, origin: Option<&str>, opts: &OutputOptions) { let content = format_with_boundaries(content, origin, opts); - print!("{}", content); + out!("{}", content); if !content.ends_with('\n') { - println!(); + outln!(); } } @@ -176,14 +242,14 @@ fn print_confirmation_required(data: &serde_json::Value) { .filter(|s| !s.is_empty()) .unwrap_or(action); - println!("Confirmation required:"); + outln!("Confirmation required:"); if category.is_empty() { - println!(" {}", description); + outln!(" {}", description); } else { - println!(" {}: {}", category, description); + outln!(" {}: {}", category, description); } - println!(" Run: agent-browser confirm {}", cid); - println!(" Or: agent-browser deny {}", cid); + outln!(" Run: agent-browser confirm {}", cid); + outln!(" Or: agent-browser deny {}", cid); } fn format_metric_ms(value: Option) -> String { @@ -402,16 +468,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou }), ); } - println!("{}", serde_json::to_string(&json_val).unwrap_or_default()); + outln!("{}", serde_json::to_string(&json_val).unwrap_or_default()); } else { - println!("{}", serde_json::to_string(resp).unwrap_or_default()); + outln!("{}", serde_json::to_string(resp).unwrap_or_default()); } // JSON mode includes the warning field in the JSON payload already return; } if !resp.success { - eprintln!( + errln!( "{} {}", color::error_indicator(), resp.error.as_deref().unwrap_or("Unknown error") @@ -419,7 +485,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // Still print dialog warning after errors, since a pending dialog // is the most common cause of commands timing out if let Some(ref warning) = resp.warning { - eprintln!("{} {}", color::warning_indicator(), warning); + errln!("{} {}", color::warning_indicator(), warning); } return; } @@ -436,7 +502,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_str()) .unwrap_or("unknown"); let message = data.get("message").and_then(|v| v.as_str()).unwrap_or(""); - println!( + outln!( "{} JavaScript {} dialog is open: \"{}\"", color::warning_indicator(), dtype, @@ -444,31 +510,31 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou ); if let Some(default_prompt) = data.get("defaultPrompt").and_then(|v| v.as_str()) { - println!(" Default prompt text: \"{}\"", default_prompt); + outln!(" Default prompt text: \"{}\"", default_prompt); } - println!(" Use `dialog accept [text]` or `dialog dismiss` to resolve it"); + outln!(" Use `dialog accept [text]` or `dialog dismiss` to resolve it"); } else { - println!("{} No dialog is currently open", color::success_indicator()); + outln!("{} No dialog is currently open", color::success_indicator()); } print_warning(resp); return; } } if let Some(output) = format_stream_status_text(action, data) { - println!("{}", output); + outln!("{}", output); return; } if action == Some("vitals") { - println!("{}", format_vitals_text(data)); + outln!("{}", format_vitals_text(data)); return; } if action == Some("a11y") { - println!("{}", format_a11y_text(data)); + outln!("{}", format_a11y_text(data)); return; } if action == Some("storage_get") { if let Some(output) = format_storage_text(data) { - println!("{}", output); + outln!("{}", output); return; } } @@ -480,12 +546,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .unwrap_or(false); if opened { if let Some(url) = data.get("url").and_then(|v| v.as_str()) { - println!("{} Opened DevTools: {}", color::success_indicator(), url); + outln!("{} Opened DevTools: {}", color::success_indicator(), url); } else { - println!("{} Opened DevTools", color::success_indicator()); + outln!("{} Opened DevTools", color::success_indicator()); } } else if let Some(err) = data.get("error").and_then(|v| v.as_str()) { - eprintln!("Could not open DevTools: {}", err); + errln!("Could not open DevTools: {}", err); } return; } @@ -502,20 +568,20 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // Navigation response if let Some(url) = data.get("url").and_then(|v| v.as_str()) { if let Some(title) = data.get("title").and_then(|v| v.as_str()) { - println!("{} {}", color::success_indicator(), color::bold(title)); - println!(" {}", color::dim(url)); + outln!("{} {}", color::success_indicator(), color::bold(title)); + outln!(" {}", color::dim(url)); return; } - println!("{}", url); + outln!("{}", url); return; } if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) { - println!("{}", cdp_url); + outln!("{}", cdp_url); return; } // Rich command reports (React renders/suspense and older daemon responses) if let Some(report) = data.get("report").and_then(|v| v.as_str()) { - println!("{}", report); + outln!("{}", report); return; } // Diff responses -- route by action to avoid fragile shape probing @@ -531,11 +597,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } Some("diff_url") => { if let Some(snap_data) = obj.get("snapshot").and_then(|v| v.as_object()) { - println!("{}", color::bold("Snapshot diff:")); + outln!("{}", color::bold("Snapshot diff:")); print_snapshot_diff(snap_data); } if let Some(ss_data) = obj.get("screenshot").and_then(|v| v.as_object()) { - println!("\n{}", color::bold("Screenshot diff:")); + outln!("\n{}", color::bold("Screenshot diff:")); print_screenshot_diff(ss_data); } return; @@ -551,7 +617,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } // Title if let Some(title) = data.get("title").and_then(|v| v.as_str()) { - println!("{}", title); + outln!("{}", title); return; } // Text @@ -566,12 +632,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } // Value if let Some(value) = data.get("value").and_then(|v| v.as_str()) { - println!("{}", value); + outln!("{}", value); return; } // Count if let Some(count) = data.get("count").and_then(|v| v.as_i64()) { - println!("{}", count); + outln!("{}", count); return; } // Bounding box (get box) @@ -581,10 +647,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou let y = obj.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0); let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0); - println!("x: {}", x); - println!("y: {}", y); - println!("width: {}", w); - println!("height: {}", h); + outln!("x: {}", x); + outln!("y: {}", y); + outln!("width: {}", w); + outln!("height: {}", h); } return; } @@ -595,21 +661,21 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou Some(s) => s.to_string(), None => val.to_string(), }; - println!("{}: {}", key, display); + outln!("{}: {}", key, display); } return; } // Boolean results if let Some(visible) = data.get("visible").and_then(|v| v.as_bool()) { - println!("{}", visible); + outln!("{}", visible); return; } if let Some(enabled) = data.get("enabled").and_then(|v| v.as_bool()) { - println!("{}", enabled); + outln!("{}", enabled); return; } if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) { - println!("{}", checked); + outln!("{}", checked); return; } // Eval result @@ -621,7 +687,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // iOS Devices if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) { if devices.is_empty() { - println!("No iOS devices available. Open Xcode to download simulator runtimes."); + outln!("No iOS devices available. Open Xcode to download simulator runtimes."); return; } @@ -644,7 +710,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .collect(); if !real_devices.is_empty() { - println!("Connected Devices:\n"); + outln!("Connected Devices:\n"); for device in real_devices.iter() { let name = device .get("name") @@ -652,14 +718,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .unwrap_or("Unknown"); let runtime = device.get("runtime").and_then(|v| v.as_str()).unwrap_or(""); let udid = device.get("udid").and_then(|v| v.as_str()).unwrap_or(""); - println!(" {} {} ({})", color::green("●"), name, runtime); - println!(" {}", color::dim(udid)); + outln!(" {} {} ({})", color::green("●"), name, runtime); + outln!(" {}", color::dim(udid)); } - println!(); + outln!(); } if !simulators.is_empty() { - println!("Simulators:\n"); + outln!("Simulators:\n"); for device in simulators.iter() { let name = device .get("name") @@ -676,8 +742,8 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } else { color::dim("○") }; - println!(" {} {} ({})", state_indicator, name, runtime); - println!(" {}", color::dim(udid)); + outln!(" {} {} ({})", state_indicator, name, runtime); + outln!(" {}", color::dim(udid)); } } return; @@ -699,9 +765,9 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou " ".to_string() }; if let Some(label) = tab_label { - println!("{} [{}] {} {} - {}", marker, tab_id, label, title, url); + outln!("{} [{}] {} {} - {}", marker, tab_id, label, title, url); } else { - println!("{} [{}] {} - {}", marker, tab_id, title, url); + outln!("{} [{}] {} - {}", marker, tab_id, title, url); } } return; @@ -717,7 +783,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou "" }; if let Some(url) = data.get("url").and_then(|v| v.as_str()) { - println!( + outln!( "{} Switched to tab [{}] ({}){}", color::success_indicator(), tab_id, @@ -725,7 +791,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou note ); } else { - println!( + outln!( "{} Switched to tab [{}]{}", color::success_indicator(), tab_id, @@ -744,7 +810,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou }; let tab_label = data.get("label").and_then(|v| v.as_str()); if let Some(lbl) = tab_label { - println!( + outln!( "{} {} [{}] {} ({} total)", color::success_indicator(), label_noun, @@ -753,7 +819,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou total ); } else { - println!( + outln!( "{} {} [{}] ({} total)", color::success_indicator(), label_noun, @@ -785,7 +851,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou for log in logs { let level = log.get("type").and_then(|v| v.as_str()).unwrap_or("log"); let text = log.get("text").and_then(|v| v.as_str()).unwrap_or(""); - println!("{} {}", color::console_level_prefix(level), text); + outln!("{} {}", color::console_level_prefix(level), text); } } return; @@ -794,7 +860,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou if let Some(errors) = data.get("errors").and_then(|v| v.as_array()) { for err in errors { let msg = err.get("message").and_then(|v| v.as_str()).unwrap_or(""); - println!("{} {}", color::error_indicator(), msg); + outln!("{} {}", color::error_indicator(), msg); } return; } @@ -803,14 +869,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou for cookie in cookies { let name = cookie.get("name").and_then(|v| v.as_str()).unwrap_or(""); let value = cookie.get("value").and_then(|v| v.as_str()).unwrap_or(""); - println!("{}={}", name, value); + outln!("{}={}", name, value); } return; } // Network requests if let Some(requests) = data.get("requests").and_then(|v| v.as_array()) { if requests.is_empty() { - println!("No requests captured"); + outln!("No requests captured"); } else { for req in requests { let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET"); @@ -822,11 +888,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou let request_id = req.get("requestId").and_then(|v| v.as_str()).unwrap_or(""); let status = req.get("status").and_then(|v| v.as_i64()); match status { - Some(s) => println!( + Some(s) => outln!( "[{}] {} {} ({}) {}", - request_id, method, url, resource_type, s + request_id, + method, + url, + resource_type, + s ), - None => println!("[{}] {} {} ({})", request_id, method, url, resource_type), + None => outln!("[{}] {} {} ({})", request_id, method, url, resource_type), } } } @@ -840,13 +910,13 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou Some("console") => "Console log cleared", _ => "Request log cleared", }; - println!("{} {}", color::success_indicator(), label); + outln!("{} {}", color::success_indicator(), label); return; } } // Bounding box if let Some(box_data) = data.get("box") { - println!( + outln!( "{}", serde_json::to_string_pretty(box_data).unwrap_or_default() ); @@ -857,14 +927,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou for (i, el) in elements.iter().enumerate() { let tag = el.get("tag").and_then(|v| v.as_str()).unwrap_or("?"); let text = el.get("text").and_then(|v| v.as_str()).unwrap_or(""); - println!("[{}] {} \"{}\"", i, tag, text); + outln!("[{}] {} \"{}\"", i, tag, text); if let Some(box_data) = el.get("box") { let w = box_data.get("width").and_then(|v| v.as_i64()).unwrap_or(0); let h = box_data.get("height").and_then(|v| v.as_i64()).unwrap_or(0); let x = box_data.get("x").and_then(|v| v.as_i64()).unwrap_or(0); let y = box_data.get("y").and_then(|v| v.as_i64()).unwrap_or(0); - println!(" box: {}x{} at ({}, {})", w, h, x, y); + outln!(" box: {}x{} at ({}, {})", w, h, x, y); } if let Some(styles) = el.get("styles") { @@ -890,14 +960,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_str()) .unwrap_or(""); - println!(" font: {} {} {}", font_size, font_weight, font_family); - println!(" color: {}", color); - println!(" background: {}", bg); + outln!(" font: {} {} {}", font_size, font_weight, font_family); + outln!(" color: {}", color); + outln!(" background: {}", bg); if radius != "0px" { - println!(" border-radius: {}", radius); + outln!(" border-radius: {}", radius); } } - println!(); + outln!(); } return; } @@ -913,7 +983,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } else { "" }; - println!( + outln!( "{} Tab [{}] closed{}", color::success_indicator(), closed_id, @@ -925,7 +995,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } _ => "Browser closed", }; - println!("{} {}", color::success_indicator(), label); + outln!("{} {}", color::success_indicator(), label); return; } // Started actions (profiling, HAR, recording) @@ -933,16 +1003,16 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou if started { match action { Some("profiler_start") => { - println!("{} Profiling started", color::success_indicator()); + outln!("{} Profiling started", color::success_indicator()); } Some("har_start") => { - println!("{} HAR recording started", color::success_indicator()); + outln!("{} HAR recording started", color::success_indicator()); } _ => { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { - println!("{} Recording started: {}", color::success_indicator(), path); + outln!("{} Recording started: {}", color::success_indicator(), path); } else { - println!("{} Recording started", color::success_indicator()); + outln!("{} Recording started", color::success_indicator()); } } } @@ -956,14 +1026,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_str()) .unwrap_or("unknown"); if let Some(prev_path) = data.get("previousPath").and_then(|v| v.as_str()) { - println!( + outln!( "{} Recording restarted: {} (previous saved to {})", color::success_indicator(), path, prev_path ); } else { - println!("{} Recording started: {}", color::success_indicator(), path); + outln!("{} Recording started: {}", color::success_indicator(), path); } return; } @@ -971,17 +1041,17 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou if data.get("frames").is_some() { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { if let Some(error) = data.get("error").and_then(|v| v.as_str()) { - println!( + outln!( "{} Recording saved to {} - {}", color::warning_indicator(), path, error ); } else { - println!("{} Recording saved to {}", color::success_indicator(), path); + outln!("{} Recording saved to {}", color::success_indicator(), path); } } else { - println!("{} Recording stopped", color::success_indicator()); + outln!("{} Recording stopped", color::success_indicator()); } return; } @@ -994,13 +1064,13 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_str()) .unwrap_or(""); if filename.is_empty() { - println!( + outln!( "{} Downloaded to {}", color::success_indicator(), color::green(path) ); } else { - println!( + outln!( "{} Downloaded to {} ({})", color::success_indicator(), color::green(path), @@ -1012,14 +1082,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } // Trace stop without path if data.get("traceStopped").is_some() { - println!("{} Trace stopped", color::success_indicator()); + outln!("{} Trace stopped", color::success_indicator()); return; } // Path-based operations (screenshot/pdf/trace/har/download/state/video) if let Some(path) = data.get("path").and_then(|v| v.as_str()) { match action.unwrap_or("") { "screenshot" => { - println!( + outln!( "{} Screenshot saved to {}", color::success_indicator(), color::green(path) @@ -1031,14 +1101,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou let role = ann.get("role").and_then(|r| r.as_str()).unwrap_or(""); let name = ann.get("name").and_then(|n| n.as_str()).unwrap_or(""); if name.is_empty() { - println!( + outln!( " {} @{} {}", color::dim(&format!("[{}]", num)), ref_id, role, ); } else { - println!( + outln!( " {} @{} {} {:?}", color::dim(&format!("[{}]", num)), ref_id, @@ -1049,23 +1119,23 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou } } } - "pdf" => println!( + "pdf" => outln!( "{} PDF saved to {}", color::success_indicator(), color::green(path) ), - "trace_stop" => println!( + "trace_stop" => outln!( "{} Trace saved to {}", color::success_indicator(), color::green(path) ), - "profiler_stop" => println!( + "profiler_stop" => outln!( "{} Profile saved to {} ({} events)", color::success_indicator(), color::green(path), data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0) ), - "har_stop" => println!( + "har_stop" => outln!( "{} HAR saved to {} ({} requests)", color::success_indicator(), color::green(path), @@ -1073,26 +1143,26 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|c| c.as_u64()) .unwrap_or(0) ), - "download" | "waitfordownload" => println!( + "download" | "waitfordownload" => outln!( "{} Download saved to {}", color::success_indicator(), color::green(path) ), - "video_stop" => println!( + "video_stop" => outln!( "{} Video saved to {}", color::success_indicator(), color::green(path) ), - "state_save" => println!( + "state_save" => outln!( "{} State saved to {}", color::success_indicator(), color::green(path) ), "state_load" => { if let Some(note) = data.get("note").and_then(|v| v.as_str()) { - println!("{}", note); + outln!("{}", note); } - println!( + outln!( "{} State path set to {}", color::success_indicator(), color::green(path) @@ -1101,11 +1171,11 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // video_start and other commands that provide a path with a note "video_start" => { if let Some(note) = data.get("note").and_then(|v| v.as_str()) { - println!("{}", note); + outln!("{}", note); } - println!("Path: {}", path); + outln!("Path: {}", path); } - _ => println!( + _ => outln!( "{} Saved to {}", color::success_indicator(), color::green(path) @@ -1117,10 +1187,10 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // State list if let Some(files) = data.get("files").and_then(|v| v.as_array()) { if let Some(dir) = data.get("directory").and_then(|v| v.as_str()) { - println!("{}", color::bold(&format!("Saved states in {}", dir))); + outln!("{}", color::bold(&format!("Saved states in {}", dir))); } if files.is_empty() { - println!("{}", color::dim(" No state files found")); + outln!("{}", color::dim(" No state files found")); } else { for file in files { let filename = file.get("filename").and_then(|v| v.as_str()).unwrap_or(""); @@ -1137,7 +1207,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou }; let date_str = modified.split('T').next().unwrap_or(modified); let enc_str = if encrypted { " [encrypted]" } else { "" }; - println!( + outln!( " {} {}", filename, color::dim(&format!("({}, {}){}", size_str, date_str, enc_str)) @@ -1151,7 +1221,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou if let Some(true) = data.get("renamed").and_then(|v| v.as_bool()) { let old_name = data.get("oldName").and_then(|v| v.as_str()).unwrap_or(""); let new_name = data.get("newName").and_then(|v| v.as_str()).unwrap_or(""); - println!( + outln!( "{} Renamed {} -> {}", color::success_indicator(), old_name, @@ -1162,7 +1232,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // State clear if let Some(cleared) = data.get("cleared").and_then(|v| v.as_i64()) { - println!( + outln!( "{} Cleared {} state file(s)", color::success_indicator(), cleared @@ -1179,15 +1249,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_bool()) .unwrap_or(false); let enc_str = if encrypted { " (encrypted)" } else { "" }; - println!("State file summary{}:", enc_str); - println!(" Cookies: {}", cookies); - println!(" Origins with localStorage: {}", origins); + outln!("State file summary{}:", enc_str); + outln!(" Cookies: {}", cookies); + outln!(" Origins with localStorage: {}", origins); return; } // State clean if let Some(cleaned) = data.get("cleaned").and_then(|v| v.as_i64()) { - println!( + outln!( "{} Cleaned {} old state file(s)", color::success_indicator(), cleaned @@ -1197,20 +1267,20 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // Informational note if let Some(note) = data.get("note").and_then(|v| v.as_str()) { - println!("{}", note); + outln!("{}", note); return; } // Auth list if let Some(profiles) = data.get("profiles").and_then(|v| v.as_array()) { if profiles.is_empty() { - println!("{}", color::dim("No auth profiles saved")); + outln!("{}", color::dim("No auth profiles saved")); } else { - println!("{}", color::bold("Auth profiles:")); + outln!("{}", color::bold("Auth profiles:")); for p in profiles { let name = p.get("name").and_then(|v| v.as_str()).unwrap_or(""); let url = p.get("url").and_then(|v| v.as_str()).unwrap_or(""); let user = p.get("username").and_then(|v| v.as_str()).unwrap_or(""); - println!( + outln!( " {} {} {}", color::green(name), color::dim(user), @@ -1234,12 +1304,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_str()) .unwrap_or(""); let last_login = profile.get("lastLoginAt").and_then(|v| v.as_str()); - println!("Name: {}", name); - println!("URL: {}", url); - println!("Username: {}", user); - println!("Created: {}", created); + outln!("Name: {}", name); + outln!("URL: {}", url); + outln!("Username: {}", user); + outln!("Created: {}", created); if let Some(ll) = last_login { - println!("Last login: {}", ll); + outln!("Last login: {}", ll); } return; } @@ -1247,7 +1317,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou // Auth save/update/login/delete if data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) { let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); - println!( + outln!( "{} Auth profile '{}' saved", color::success_indicator(), name @@ -1261,7 +1331,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou && !data.get("saved").and_then(|v| v.as_bool()).unwrap_or(false) { let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); - println!( + outln!( "{} Auth profile '{}' updated", color::success_indicator(), name @@ -1275,14 +1345,14 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou { let name = data.get("name").and_then(|v| v.as_str()).unwrap_or(""); if let Some(title) = data.get("title").and_then(|v| v.as_str()) { - println!( + outln!( "{} Logged in as '{}' - {}", color::success_indicator(), name, title ); } else { - println!("{} Logged in as '{}'", color::success_indicator(), name); + outln!("{} Logged in as '{}'", color::success_indicator(), name); } return; } @@ -1292,7 +1362,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .unwrap_or(false) { if let Some(name) = data.get("name").and_then(|v| v.as_str()) { - println!( + outln!( "{} Auth profile '{}' deleted", color::success_indicator(), name @@ -1311,7 +1381,7 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_bool()) .unwrap_or(false) { - println!("{} Action confirmed", color::success_indicator()); + outln!("{} Action confirmed", color::success_indicator()); return; } if data @@ -1319,12 +1389,12 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .and_then(|v| v.as_bool()) .unwrap_or(false) { - println!("{} Action denied", color::success_indicator()); + outln!("{} Action denied", color::success_indicator()); return; } // Default success - println!("{} Done", color::success_indicator()); + outln!("{} Done", color::success_indicator()); } print_warning(resp); @@ -1368,13 +1438,13 @@ fn print_lifecycle_note(data: &serde_json::Value) { } if !parts.is_empty() { - eprintln!("{} {}", color::dim("[agent-browser]"), parts.join("; ")); + errln!("{} {}", color::dim("[agent-browser]"), parts.join("; ")); } } fn print_warning(resp: &Response) { if let Some(ref warning) = resp.warning { - eprintln!("{} {}", color::warning_indicator(), warning); + errln!("{} {}", color::warning_indicator(), warning); } } @@ -3475,12 +3545,12 @@ Examples: _ => return false, }; - println!("{}", help.trim()); + outln!("{}", help.trim()); true } pub fn print_help() { - println!( + outln!( r#" agent-browser - fast browser automation CLI for AI agents @@ -3877,23 +3947,23 @@ fn print_snapshot_diff(data: &serde_json::Map) { .and_then(|v| v.as_bool()) .unwrap_or(false); if !changed { - println!("{} No changes detected", color::success_indicator()); + outln!("{} No changes detected", color::success_indicator()); return; } if let Some(diff) = data.get("diff").and_then(|v| v.as_str()) { for line in diff.lines() { if line.starts_with("+ ") { - println!("{}", color::green(line)); + outln!("{}", color::green(line)); } else if line.starts_with("- ") { - println!("{}", color::red(line)); + outln!("{}", color::red(line)); } else { - println!("{}", color::dim(line)); + outln!("{}", color::dim(line)); } } let additions = data.get("additions").and_then(|v| v.as_i64()).unwrap_or(0); let removals = data.get("removals").and_then(|v| v.as_i64()).unwrap_or(0); let unchanged = data.get("unchanged").and_then(|v| v.as_i64()).unwrap_or(0); - println!( + outln!( "\n{} additions, {} removals, {} unchanged", color::green(&additions.to_string()), color::red(&removals.to_string()), @@ -3913,24 +3983,24 @@ fn print_screenshot_diff(data: &serde_json::Map) { .and_then(|v| v.as_bool()) .unwrap_or(false); if dim_mismatch { - println!( + outln!( "{} Images have different dimensions", color::error_indicator() ); } else if is_match { - println!( + outln!( "{} Images match (0% difference)", color::success_indicator() ); } else { - println!( + outln!( "{} {:.2}% pixels differ", color::error_indicator(), mismatch ); } if let Some(diff_path) = data.get("diffPath").and_then(|v| v.as_str()) { - println!(" Diff image: {}", color::green(diff_path)); + outln!(" Diff image: {}", color::green(diff_path)); } let total = data .get("totalPixels") @@ -3940,7 +4010,7 @@ fn print_screenshot_diff(data: &serde_json::Map) { .get("differentPixels") .and_then(|v| v.as_i64()) .unwrap_or(0); - println!( + outln!( " {} different / {} total pixels", color::red(&different.to_string()), total @@ -3948,7 +4018,7 @@ fn print_screenshot_diff(data: &serde_json::Map) { } pub fn print_version() { - println!("agent-browser {}", env!("CARGO_PKG_VERSION")); + outln!("agent-browser {}", env!("CARGO_PKG_VERSION")); } #[cfg(test)] From 1be33dd51dc09242c1ec051513eeb97f69cbc70e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:20:25 +0000 Subject: [PATCH 10/11] feat: route artifact writes through a pluggable artifact writer Co-Authored-By: glavin@coframe.com --- cli/src/artifacts.rs | 65 ++++++++++++++++++++++++++++++++++++ cli/src/lib.rs | 1 + cli/src/native/actions.rs | 8 +++-- cli/src/native/screenshot.rs | 2 +- 4 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 cli/src/artifacts.rs diff --git a/cli/src/artifacts.rs b/cli/src/artifacts.rs new file mode 100644 index 000000000..43558ed41 --- /dev/null +++ b/cli/src/artifacts.rs @@ -0,0 +1,65 @@ +//! Pluggable artifact persistence. Commands that produce files (screenshots, +//! PDFs, HARs, diffs) write through [`write`] so library hosts without a real +//! filesystem (e.g. wasm) can install their own writer via +//! [`set_artifact_writer`]. When no writer is installed, [`write`] falls back +//! to `std::fs::write`, preserving native CLI behavior. + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use std::sync::OnceLock; + + pub type ArtifactWriter = Box Result<(), String> + Send + Sync>; + + static WRITER: OnceLock = OnceLock::new(); + + /// Install a process-global artifact writer. Returns an error if a writer + /// was already installed. + pub fn set_artifact_writer(writer: ArtifactWriter) -> Result<(), String> { + WRITER + .set(writer) + .map_err(|_| "artifact writer already installed".to_string()) + } + + pub fn write(path: &str, bytes: &[u8]) -> Result<(), String> { + if let Some(writer) = WRITER.get() { + return writer(path, bytes); + } + std::fs::write(path, bytes).map_err(|e| format!("Failed to write {}: {}", path, e)) + } +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use std::cell::RefCell; + + pub type ArtifactWriter = Box Result<(), String>>; + + thread_local! { + static WRITER: RefCell> = const { RefCell::new(None) }; + } + + /// Install a process-global artifact writer. Returns an error if a writer + /// was already installed. + pub fn set_artifact_writer(writer: ArtifactWriter) -> Result<(), String> { + WRITER.with(|w| { + let mut slot = w.borrow_mut(); + if slot.is_some() { + return Err("artifact writer already installed".to_string()); + } + *slot = Some(writer); + Ok(()) + }) + } + + pub fn write(path: &str, bytes: &[u8]) -> Result<(), String> { + WRITER.with(|w| match w.borrow().as_ref() { + Some(writer) => writer(path, bytes), + None => Err(format!( + "cannot write {}: no artifact writer installed on this platform", + path + )), + }) + } +} + +pub use imp::{set_artifact_writer, write, ArtifactWriter}; diff --git a/cli/src/lib.rs b/cli/src/lib.rs index ed70584c0..084cd7cfe 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,3 +1,4 @@ +pub mod artifacts; #[cfg(not(target_arch = "wasm32"))] pub mod chat; pub mod color; diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index dce4af822..5fc93d073 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -6635,7 +6635,8 @@ async fn handle_pdf(cmd: &Value, state: &DaemonState) -> Result { let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data) .map_err(|e| format!("Failed to decode PDF: {}", e))?; - std::fs::write(&save_path, &bytes).map_err(|e| format!("Failed to save PDF: {}", e))?; + crate::artifacts::write(&save_path, &bytes) + .map_err(|e| format!("Failed to save PDF: {}", e))?; Ok(json!({ "path": save_path })) } @@ -9266,7 +9267,7 @@ async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result Result Date: Thu, 6 Aug 2026 22:35:50 +0000 Subject: [PATCH 11/11] fix: make gen_id use js time on wasm32 where SystemTime panics Co-Authored-By: glavin@coframe.com --- cli/src/commands.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 904b59567..abfedb2e6 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -61,14 +61,21 @@ impl ParseError { } pub fn gen_id() -> String { - format!( - "r{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_micros() - % 1000000 - ) + format!("r{}", now_micros() % 1000000) +} + +#[cfg(not(target_arch = "wasm32"))] +fn now_micros() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() +} + +// SystemTime::now is unsupported and panics on wasm32-unknown-unknown. +#[cfg(target_arch = "wasm32")] +fn now_micros() -> u128 { + (js_sys::Date::now() * 1000.0) as u128 } /// Normalize browser navigation inputs while preserving schemes Chrome can