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/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/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 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..133b45898 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; @@ -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") diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 000000000..084cd7cfe --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,24 @@ +pub mod artifacts; +#[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 304ca570b..c98ce0cd8 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, + 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" }); diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 59f010544..5fc93d073 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 { 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 })) } @@ -7267,7 +7278,7 @@ async fn handle_vitals(cmd: &Value, state: &mut DaemonState) -> Result Result Result Result Result { if event.method == "Network.responseReceived" && event.session_id.as_deref() == Some(&session_id) @@ -9090,15 +9101,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. @@ -9256,7 +9267,7 @@ async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result Result 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 +10361,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 +10624,11 @@ async fn handle_auth_login(cmd: &Value, state: &mut DaemonState) -> Result { if event.session_id.as_deref() == Some(&session_id) { @@ -10637,7 +10649,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 +10773,7 @@ async fn handle_swipe(cmd: &Value, state: &mut DaemonState) -> Result Result 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(); @@ -526,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, @@ -747,7 +759,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 +794,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 +956,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 +983,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 +1097,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 +1117,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 +1851,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 +1878,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 +1899,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 +1934,7 @@ async fn connect_cdp_with_retry( } } - tokio::time::sleep(poll_interval).await; + crate::rt::sleep(poll_interval).await; } } @@ -1946,7 +1958,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 +1987,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 +2020,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))), } diff --git a/cli/src/native/cdp/chrome.rs b/cli/src/native/cdp/chrome.rs index 49e46091f..101f164af 100644 --- a/cli/src/native/cdp/chrome.rs +++ b/cli/src/native/cdp/chrome.rs @@ -660,7 +660,7 @@ fn try_launch_chrome(chrome_path: &Path, options: &LaunchOptions) -> Result 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; @@ -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() { 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, diff --git a/cli/src/native/cdp/discovery.rs b/cli/src/native/cdp/discovery.rs index 23d425918..4e6c59d78 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,6 +163,12 @@ 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); diff --git a/cli/src/native/cdp/lightpanda.rs b/cli/src/native/cdp/lightpanda.rs index dae3d6b5e..d4042fe57 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; } } 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/inspect_server.rs b/cli/src/native/inspect_server.rs index 1c151a4d8..b700c0f99 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. /// @@ -69,6 +98,7 @@ impl InspectServer { } } +#[cfg(not(target_arch = "wasm32"))] async fn accept_loop( listener: TcpListener, proxy: Arc, @@ -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, @@ -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/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 { diff --git a/cli/src/native/recording.rs b/cli/src/native/recording.rs index cdd51c206..96af67d48 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,13 +135,27 @@ 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> { +) -> crate::rt::JoinHandle> { tokio::spawn(async move { let mut cancel_rx = std::pin::pin!(cancel_rx); diff --git a/cli/src/native/screenshot.rs b/cli/src/native/screenshot.rs index 0736691f3..b7a539e6e 100644 --- a/cli/src/native/screenshot.rs +++ b/cli/src/native/screenshot.rs @@ -577,7 +577,7 @@ fn save_screenshot( let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data) .map_err(|e| format!("Failed to decode screenshot: {}", e))?; - std::fs::write(&save_path, &bytes) + crate::artifacts::write(&save_path, &bytes) .map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?; Ok(save_path) 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..017246dc2 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>>>, 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..555f8427d 100644 --- a/cli/src/native/webdriver/appium.rs +++ b/cli/src/native/webdriver/appium.rs @@ -152,6 +152,12 @@ 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( @@ -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..47c8be443 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"); 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)] diff --git a/cli/src/plugins.rs b/cli/src/plugins.rs index bf286c996..3234654fc 100644 --- a/cli/src/plugins.rs +++ b/cli/src/plugins.rs @@ -6,9 +6,13 @@ use serde::{Deserialize, Serialize}; use serde_json::json; +#[cfg(not(target_arch = "wasm32"))] use std::fs; +#[cfg(not(target_arch = "wasm32"))] use std::path::{Path, PathBuf}; +#[cfg(not(target_arch = "wasm32"))] use std::process::Stdio; +#[cfg(not(target_arch = "wasm32"))] use tokio::io::AsyncWriteExt; pub const PROTOCOL_VERSION: &str = "agent-browser.plugin.v1"; @@ -111,6 +115,7 @@ struct LaunchMutationPluginResponse { data: Option, } +#[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, @@ -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 })); diff --git a/cli/src/read.rs b/cli/src/read.rs index 488c046e3..c1ff9edc6 100644 --- a/cli/src/read.rs +++ b/cli/src/read.rs @@ -1,3 +1,6 @@ +// 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; @@ -186,6 +189,15 @@ 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)?; diff --git a/cli/src/rt.rs b/cli/src/rt.rs new file mode 100644 index 000000000..98037a2cb --- /dev/null +++ b/cli/src/rt.rs @@ -0,0 +1,297 @@ +//! 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}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + /// 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, + finished: Arc, + } + + impl JoinHandle { + pub fn abort(&self) { + self.abort_handle.abort(); + } + + pub fn is_finished(&self) -> bool { + self.finished.load(Ordering::Acquire) || 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); + let finished = Arc::new(AtomicBool::new(false)); + let finished_flag = finished.clone(); + wasm_bindgen_futures::spawn_local(async move { + let result = task.await; + finished_flag.store(true, Ordering::Release); + if let Ok(output) = result { + let _ = sender.send(output); + } + }); + JoinHandle { + receiver, + abort_handle, + finished, + } + } + + pub fn spawn_blocking(f: F) -> JoinHandle + where + F: FnOnce() -> T + 'static, + T: 'static, + { + spawn(async move { f() }) + } +} + +pub use imp::*;