diff --git a/Cargo.lock b/Cargo.lock index 3b58bb3..b3005a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -355,6 +355,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -375,6 +396,18 @@ dependencies = [ "litrs", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -854,6 +887,15 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -954,7 +996,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opencli-rs" -version = "0.1.0" +version = "0.1.1" dependencies = [ "clap", "clap_complete", @@ -975,7 +1017,7 @@ dependencies = [ [[package]] name = "opencli-rs-ai" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "opencli-rs-browser", @@ -991,10 +1033,11 @@ dependencies = [ [[package]] name = "opencli-rs-browser" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "axum", + "dirs", "futures", "opencli-rs-core", "reqwest", @@ -1005,11 +1048,12 @@ dependencies = [ "tokio-tungstenite 0.24.0", "tracing", "uuid", + "which", ] [[package]] name = "opencli-rs-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "serde", @@ -1020,7 +1064,7 @@ dependencies = [ [[package]] name = "opencli-rs-discovery" -version = "0.1.0" +version = "0.1.1" dependencies = [ "opencli-rs-core", "opencli-rs-pipeline", @@ -1033,7 +1077,7 @@ dependencies = [ [[package]] name = "opencli-rs-external" -version = "0.1.0" +version = "0.1.1" dependencies = [ "opencli-rs-core", "serde", @@ -1046,7 +1090,7 @@ dependencies = [ [[package]] name = "opencli-rs-output" -version = "0.1.0" +version = "0.1.1" dependencies = [ "colored", "comfy-table", @@ -1059,7 +1103,7 @@ dependencies = [ [[package]] name = "opencli-rs-pipeline" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "base64", @@ -1076,6 +1120,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1249,7 +1299,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1341,6 +1391,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2241,6 +2302,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2269,13 +2342,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2284,7 +2366,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2296,34 +2378,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2336,30 +2451,60 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/crates/opencli-rs-browser/Cargo.toml b/crates/opencli-rs-browser/Cargo.toml index 690ee9e..140d322 100644 --- a/crates/opencli-rs-browser/Cargo.toml +++ b/crates/opencli-rs-browser/Cargo.toml @@ -12,6 +12,8 @@ async-trait = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +which = "7" +dirs = "5" tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } axum = { version = "0.8", features = ["ws"] } uuid = { version = "1", features = ["v4"] } diff --git a/crates/opencli-rs-browser/examples/direct_cdp.rs b/crates/opencli-rs-browser/examples/direct_cdp.rs new file mode 100644 index 0000000..9492a77 --- /dev/null +++ b/crates/opencli-rs-browser/examples/direct_cdp.rs @@ -0,0 +1,72 @@ +//! End-to-end test: connect via direct CDP and interact with the browser. +//! +//! Run with: cargo run -p opencli-rs-browser --example direct_cdp + +use opencli_rs_browser::browser_launcher; +use opencli_rs_browser::CdpPage; +use opencli_rs_core::IPage; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("=== Direct CDP Connection Test ===\n"); + + // Step 1: Get CDP endpoint (reuse existing or launch headless) + println!("[1] Connecting to CDP..."); + let endpoint = browser_launcher::connect_or_launch().await?; + println!( + " endpoint: {} (launched: {})\n", + endpoint.ws_url, endpoint.launched + ); + + let page = CdpPage::connect(&endpoint.ws_url).await?; + + // Step 2: Navigate to X search + let url = "https://x.com/search?q=rust+programming&f=live"; + println!("[2] Navigating to {url}..."); + page.goto(url, None).await?; + page.wait_for_timeout(5000).await?; + + let title = page.title().await?; + let current_url = page.url().await?; + println!(" Title: {title}"); + println!(" URL: {current_url}\n"); + + // Step 3: Extract tweet text from the page + println!("[3] Extracting tweets...\n"); + let js = r#" + (function() { + const tweets = []; + const articles = document.querySelectorAll('article[data-testid="tweet"]'); + articles.forEach((article, i) => { + if (i >= 10) return; + const userEl = article.querySelector('[data-testid="User-Name"]'); + const textEl = article.querySelector('[data-testid="tweetText"]'); + const user = userEl ? userEl.innerText.replace(/\n/g, ' ') : '?'; + const text = textEl ? textEl.innerText : ''; + if (text) { + tweets.push({ user: user, text: text }); + } + }); + return JSON.stringify(tweets); + })() + "#; + + let result = page.evaluate(js).await?; + let json_str = result.as_str().unwrap_or("[]"); + let tweets: Vec = serde_json::from_str(json_str).unwrap_or_default(); + + if tweets.is_empty() { + println!(" No tweets found (page may still be loading)"); + } else { + for (i, tweet) in tweets.iter().enumerate() { + let user = tweet["user"].as_str().unwrap_or("?"); + let text = tweet["text"].as_str().unwrap_or(""); + println!("--- Tweet {} ---", i + 1); + println!(" @{}", user); + println!(" {}\n", text); + } + } + + println!("=== Done ==="); + Ok(()) +} diff --git a/crates/opencli-rs-browser/examples/direct_cdp_headed.rs b/crates/opencli-rs-browser/examples/direct_cdp_headed.rs new file mode 100644 index 0000000..a30c3c0 --- /dev/null +++ b/crates/opencli-rs-browser/examples/direct_cdp_headed.rs @@ -0,0 +1,68 @@ +//! End-to-end test: headed browser with CDP and cookie sync. +//! +//! Run with: cargo run -p opencli-rs-browser --example direct_cdp_headed + +use opencli_rs_browser::browser_launcher; +use opencli_rs_browser::CdpPage; +use opencli_rs_core::IPage; + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("=== Headed CDP Test ===\n"); + + println!("[1] Launching headed browser with CDP..."); + let endpoint = browser_launcher::connect_or_launch_with(false).await?; + println!( + " endpoint: {} (launched: {})\n", + endpoint.ws_url, endpoint.launched + ); + + let page = CdpPage::connect(&endpoint.ws_url).await?; + + let url = "https://x.com/search?q=rust+programming&f=live"; + println!("[2] Navigating to {url}..."); + page.goto(url, None).await?; + page.wait_for_timeout(5000).await?; + + let title = page.title().await?; + let current_url = page.url().await?; + println!(" Title: {title}"); + println!(" URL: {current_url}\n"); + + if current_url.contains("/login") { + println!(" ⚠ Redirected to login — cookies not available"); + } else { + println!(" ✓ Authenticated!\n"); + + println!("[3] Extracting tweets...\n"); + let js = r#" + (function() { + const tweets = []; + const articles = document.querySelectorAll('article[data-testid="tweet"]'); + articles.forEach((article, i) => { + if (i >= 5) return; + const userEl = article.querySelector('[data-testid="User-Name"]'); + const textEl = article.querySelector('[data-testid="tweetText"]'); + const user = userEl ? userEl.innerText.replace(/\n/g, ' ') : '?'; + const text = textEl ? textEl.innerText : ''; + if (text) { tweets.push({ user: user, text: text }); } + }); + return JSON.stringify(tweets); + })() + "#; + let result = page.evaluate(js).await?; + let json_str = result.as_str().unwrap_or("[]"); + let tweets: Vec = serde_json::from_str(json_str).unwrap_or_default(); + for (i, tweet) in tweets.iter().enumerate() { + println!( + "--- Tweet {} ---\n @{}\n {}\n", + i + 1, + tweet["user"].as_str().unwrap_or("?"), + tweet["text"].as_str().unwrap_or("") + ); + } + } + + println!("=== Done (browser window stays open) ==="); + Ok(()) +} diff --git a/crates/opencli-rs-browser/src/bridge.rs b/crates/opencli-rs-browser/src/bridge.rs index e6c438d..8c172e8 100644 --- a/crates/opencli-rs-browser/src/bridge.rs +++ b/crates/opencli-rs-browser/src/bridge.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use std::time::Duration; use tracing::{debug, info, warn}; +use crate::browser_launcher; +use crate::cdp::CdpPage; use crate::daemon_client::DaemonClient; use crate::page::DaemonPage; @@ -29,8 +31,40 @@ impl BrowserBridge { Self::new(DEFAULT_PORT) } - /// Connect to the daemon, starting it if necessary, and return a page. + /// Connect to a browser and return a page. + /// + /// Strategy: try direct CDP first (no extension needed), then fall back + /// to the daemon + Chrome extension flow. pub async fn connect(&mut self) -> Result, CliError> { + // Strategy 1: Direct CDP — discover existing or launch browser with CDP + match browser_launcher::connect_or_launch().await { + Ok(endpoint) => { + info!( + ws_url = %endpoint.ws_url, + launched = endpoint.launched, + "direct CDP endpoint available" + ); + match CdpPage::connect(&endpoint.ws_url).await { + Ok(page) => { + info!("connected via direct CDP (no extension required)"); + return Ok(Arc::new(page)); + } + Err(e) => { + warn!("direct CDP connection failed, falling back to daemon: {e}"); + } + } + } + Err(e) => { + debug!("direct CDP not available: {e}, trying daemon+extension"); + } + } + + // Strategy 2: Daemon + Chrome extension (original flow) + self.connect_via_daemon().await + } + + /// Original daemon+extension connection flow. + async fn connect_via_daemon(&mut self) -> Result, CliError> { let client = Arc::new(DaemonClient::new(self.port)); // Step 1: Check Chrome is running @@ -55,7 +89,10 @@ impl BrowserBridge { } // Step 3: Wait up to 5s for extension to connect - if self.poll_extension(&client, EXTENSION_INITIAL_WAIT, false).await { + if self + .poll_extension(&client, EXTENSION_INITIAL_WAIT, false) + .await + { let page = DaemonPage::new(client, "default"); return Ok(Arc::new(page)); } @@ -66,7 +103,10 @@ impl BrowserBridge { wake_chrome(); // Step 5: Wait remaining 25s with progress - if self.poll_extension(&client, EXTENSION_REMAINING_WAIT, true).await { + if self + .poll_extension(&client, EXTENSION_REMAINING_WAIT, true) + .await + { let page = DaemonPage::new(client, "default"); return Ok(Arc::new(page)); } @@ -239,4 +279,14 @@ mod tests { let bridge = BrowserBridge::default_port(); assert_eq!(bridge.port, DEFAULT_PORT); } + + #[tokio::test] + async fn test_connect_completes_without_panic() { + // Verify the two-strategy connect flow runs without panicking. + // Result depends on whether a browser is running locally: + // - Ok if direct CDP finds a running browser + // - Err if no browser/CDP available and daemon flow also fails + let mut bridge = BrowserBridge::new(19899); + let _result = bridge.connect().await; + } } diff --git a/crates/opencli-rs-browser/src/browser_detection.rs b/crates/opencli-rs-browser/src/browser_detection.rs new file mode 100644 index 0000000..6111b1c --- /dev/null +++ b/crates/opencli-rs-browser/src/browser_detection.rs @@ -0,0 +1,524 @@ +//! Native browser detection for Chromium-based browsers. +//! +//! Detects the user's default browser, resolves its executable path and +//! user-data directory (where cookies/logins live). Supports Chrome, Brave, +//! Edge, Arc, Vivaldi, Opera, and Chromium across macOS, Linux, and Windows. + +use std::path::PathBuf; + +/// Detected browser info. +#[derive(Debug, Clone)] +pub struct BrowserInfo { + pub name: String, + pub path: PathBuf, + /// The browser's native user-data directory (where cookies/logins live). + pub user_data_dir: Option, +} + +/// Known Chromium-based browser with platform-specific metadata. +struct BrowserCandidate { + name: &'static str, + /// Bundle ID (macOS) or desktop file (Linux) or ProgId (Windows). + #[cfg(target_os = "macos")] + bundle_id: &'static str, + #[cfg(target_os = "linux")] + desktop_file: &'static str, + #[cfg(target_os = "windows")] + prog_id: &'static str, + /// Known executable paths per platform. + paths: &'static [&'static str], + /// Names for PATH lookup (e.g. "brave-browser", "google-chrome"). + which_names: &'static [&'static str], + /// User data dir relative to platform config root. + #[cfg(target_os = "macos")] + profile_dir: Option<&'static str>, + #[cfg(target_os = "linux")] + profile_dir: Option<&'static str>, + #[cfg(target_os = "windows")] + profile_dir: Option<&'static str>, +} + +/// Known Chromium-based browsers in preference order (most popular first). +fn known_browsers() -> Vec { + vec![ + BrowserCandidate { + name: "Google Chrome", + #[cfg(target_os = "macos")] + bundle_id: "com.google.chrome", + #[cfg(target_os = "linux")] + desktop_file: "google-chrome.desktop", + #[cfg(target_os = "windows")] + prog_id: "ChromeHTML", + paths: if cfg!(target_os = "macos") { + &["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"] + } else if cfg!(target_os = "windows") { + &[ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + ] + } else { + &["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome"] + }, + which_names: &["google-chrome-stable", "google-chrome"], + #[cfg(target_os = "macos")] + profile_dir: Some("Google/Chrome"), + #[cfg(target_os = "linux")] + profile_dir: Some("google-chrome"), + #[cfg(target_os = "windows")] + profile_dir: Some(r"Google\Chrome\User Data"), + }, + BrowserCandidate { + name: "Brave", + #[cfg(target_os = "macos")] + bundle_id: "com.brave.Browser", + #[cfg(target_os = "linux")] + desktop_file: "brave-browser.desktop", + #[cfg(target_os = "windows")] + prog_id: "BraveHTML", + paths: if cfg!(target_os = "macos") { + &["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"] + } else if cfg!(target_os = "windows") { + &[r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe"] + } else { + &[ + "/usr/bin/brave-browser", + "/usr/bin/brave", + "/opt/brave.com/brave/brave", + ] + }, + which_names: &["brave-browser", "brave"], + #[cfg(target_os = "macos")] + profile_dir: Some("BraveSoftware/Brave-Browser"), + #[cfg(target_os = "linux")] + profile_dir: Some("BraveSoftware/Brave-Browser"), + #[cfg(target_os = "windows")] + profile_dir: Some(r"BraveSoftware\Brave-Browser\User Data"), + }, + BrowserCandidate { + name: "Microsoft Edge", + #[cfg(target_os = "macos")] + bundle_id: "com.microsoft.edgemac", + #[cfg(target_os = "linux")] + desktop_file: "microsoft-edge.desktop", + #[cfg(target_os = "windows")] + prog_id: "MSEdgeHTM", + paths: if cfg!(target_os = "macos") { + &["/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"] + } else if cfg!(target_os = "windows") { + &[ + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + ] + } else { + &["/usr/bin/microsoft-edge", "/opt/microsoft/msedge/msedge"] + }, + which_names: &["microsoft-edge", "msedge"], + #[cfg(target_os = "macos")] + profile_dir: Some("Microsoft Edge"), + #[cfg(target_os = "linux")] + profile_dir: Some("microsoft-edge"), + #[cfg(target_os = "windows")] + profile_dir: Some(r"Microsoft\Edge\User Data"), + }, + BrowserCandidate { + name: "Arc", + #[cfg(target_os = "macos")] + bundle_id: "company.thebrowser.Browser", + #[cfg(target_os = "linux")] + desktop_file: "", + #[cfg(target_os = "windows")] + prog_id: "", + paths: if cfg!(target_os = "macos") { + &["/Applications/Arc.app/Contents/MacOS/Arc"] + } else { + &[] + }, + which_names: &[], + #[cfg(target_os = "macos")] + profile_dir: Some("Arc/User Data"), + #[cfg(target_os = "linux")] + profile_dir: None, + #[cfg(target_os = "windows")] + profile_dir: None, + }, + BrowserCandidate { + name: "Vivaldi", + #[cfg(target_os = "macos")] + bundle_id: "com.vivaldi.Vivaldi", + #[cfg(target_os = "linux")] + desktop_file: "vivaldi-stable.desktop", + #[cfg(target_os = "windows")] + prog_id: "VivaldiHTM", + paths: if cfg!(target_os = "macos") { + &["/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"] + } else if cfg!(target_os = "windows") { + &[r"C:\Program Files\Vivaldi\Application\vivaldi.exe"] + } else { + &["/usr/bin/vivaldi", "/opt/vivaldi/vivaldi"] + }, + which_names: &["vivaldi"], + #[cfg(target_os = "macos")] + profile_dir: Some("Vivaldi"), + #[cfg(target_os = "linux")] + profile_dir: Some("vivaldi"), + #[cfg(target_os = "windows")] + profile_dir: Some(r"Vivaldi\User Data"), + }, + BrowserCandidate { + name: "Opera", + #[cfg(target_os = "macos")] + bundle_id: "com.operasoftware.Opera", + #[cfg(target_os = "linux")] + desktop_file: "opera.desktop", + #[cfg(target_os = "windows")] + prog_id: "OperaStable", + paths: if cfg!(target_os = "macos") { + &["/Applications/Opera.app/Contents/MacOS/Opera"] + } else if cfg!(target_os = "windows") { + &[r"C:\Program Files\Opera\launcher.exe"] + } else { + &["/usr/bin/opera"] + }, + which_names: &["opera"], + #[cfg(target_os = "macos")] + profile_dir: Some("com.operasoftware.Opera"), + #[cfg(target_os = "linux")] + profile_dir: Some("opera"), + #[cfg(target_os = "windows")] + profile_dir: Some(r"Opera Software\Opera Stable"), + }, + BrowserCandidate { + name: "Chromium", + #[cfg(target_os = "macos")] + bundle_id: "org.chromium.Chromium", + #[cfg(target_os = "linux")] + desktop_file: "chromium-browser.desktop", + #[cfg(target_os = "windows")] + prog_id: "ChromiumHTM", + paths: if cfg!(target_os = "macos") { + &["/Applications/Chromium.app/Contents/MacOS/Chromium"] + } else if cfg!(target_os = "windows") { + &[r"C:\Program Files\Chromium\Application\chrome.exe"] + } else { + &["/usr/bin/chromium-browser", "/usr/bin/chromium"] + }, + which_names: &["chromium-browser", "chromium"], + #[cfg(target_os = "macos")] + profile_dir: Some("Chromium"), + #[cfg(target_os = "linux")] + profile_dir: Some("chromium"), + #[cfg(target_os = "windows")] + profile_dir: Some(r"Chromium\User Data"), + }, + ] +} + +/// Find the executable path for a browser candidate. +fn find_executable(candidate: &BrowserCandidate) -> Option { + for path in candidate.paths { + let p = PathBuf::from(path); + if p.exists() { + return Some(p); + } + } + for name in candidate.which_names { + if let Ok(p) = which::which(name) { + return Some(p); + } + } + None +} + +/// Resolve the browser's native user-data directory. +fn resolve_profile_dir(candidate: &BrowserCandidate) -> Option { + #[cfg(target_os = "macos")] + let base = dirs::home_dir()?.join("Library/Application Support"); + #[cfg(target_os = "linux")] + let base = dirs::config_dir()?; + #[cfg(target_os = "windows")] + let base = dirs::data_local_dir()?; + + let rel = candidate.profile_dir?; + let dir = base.join(rel); + if dir.exists() { + Some(dir) + } else { + None + } +} + +/// Check if a profile directory is locked by a running browser instance. +pub fn is_profile_locked(profile_dir: &std::path::Path) -> bool { + let lock = profile_dir.join("SingletonLock"); + if lock.exists() { + return true; + } + let lock2 = profile_dir.join("Lock"); + if lock2.exists() { + return true; + } + profile_dir.join("SingletonSocket").exists() +} + +/// Detect the user's default browser (macOS). +/// +/// Parses the LaunchServices plist to find which app handles `https`. +/// Each handler block contains `LSHandlerRoleAll` and `LSHandlerURLScheme` +/// in arbitrary order, so we collect both within each `{ ... }` block. +#[cfg(target_os = "macos")] +fn detect_default_browser_id() -> Option { + let output = std::process::Command::new("defaults") + .args([ + "read", + "com.apple.LaunchServices/com.apple.launchservices.secure", + "LSHandlers", + ]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout); + + let mut role_all: Option = None; + let mut is_https = false; + + for line in text.lines() { + let trimmed = line.trim(); + + // Start of a new handler block — reset state + if trimmed == "{" { + role_all = None; + is_https = false; + } + + // Capture the RoleAll value (skip the nested PreferredVersions one) + if trimmed.starts_with("LSHandlerRoleAll") && !trimmed.contains("-") { + if let Some(eq) = trimmed.find('=') { + let val = trimmed[eq + 1..] + .trim() + .trim_matches(';') + .trim() + .trim_matches('"'); + if !val.is_empty() && val != "-" { + role_all = Some(val.to_lowercase()); + } + } + } + + // Check if this block handles https + if trimmed.contains("LSHandlerURLScheme") && trimmed.contains("https") { + is_https = true; + } + + // End of block — check if we found what we need + if trimmed.starts_with("}") || trimmed.starts_with("},") { + if is_https { + if let Some(id) = role_all.take() { + return Some(id); + } + } + role_all = None; + is_https = false; + } + } + None +} + +/// Detect the user's default browser (Linux). +#[cfg(target_os = "linux")] +fn detect_default_browser_id() -> Option { + let output = std::process::Command::new("xdg-settings") + .args(["get", "default-web-browser"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout) + .trim() + .to_lowercase(); + if text.is_empty() { + None + } else { + Some(text) + } +} + +/// Detect the user's default browser (Windows). +#[cfg(target_os = "windows")] +fn detect_default_browser_id() -> Option { + let output = std::process::Command::new("reg") + .args([ + "query", + r"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice", + "/v", + "ProgId", + ]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("ProgId") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if let Some(id) = parts.last() { + return Some(id.to_lowercase()); + } + } + } + None +} + +/// Check if a browser candidate matches the detected default browser ID. +fn matches_default(candidate: &BrowserCandidate, default_id: &str) -> bool { + let id = default_id.to_lowercase(); + #[cfg(target_os = "macos")] + { + candidate.bundle_id.to_lowercase() == id + } + #[cfg(target_os = "linux")] + { + candidate.desktop_file.to_lowercase() == id + } + #[cfg(target_os = "windows")] + { + candidate.prog_id.to_lowercase() == id + } +} + +/// Smart browser detection: finds the user's default browser, then falls back +/// to the first installed Chromium-based browser. +pub fn detect_browser() -> Option { + let browsers = known_browsers(); + + // 1. Try the user's default browser first + if let Some(default_id) = detect_default_browser_id() { + tracing::debug!("Default browser identifier: {default_id}"); + for candidate in &browsers { + if matches_default(candidate, &default_id) { + if let Some(path) = find_executable(candidate) { + tracing::info!( + "Default browser detected: {} ({})", + candidate.name, + default_id + ); + return Some(BrowserInfo { + name: candidate.name.to_string(), + path, + user_data_dir: resolve_profile_dir(candidate), + }); + } + } + } + tracing::debug!("Default browser '{default_id}' is not Chromium-based or not found"); + } + + // 2. Fall back to first installed Chromium browser + for candidate in &browsers { + if let Some(path) = find_executable(candidate) { + tracing::info!("Found Chromium browser: {}", candidate.name); + return Some(BrowserInfo { + name: candidate.name.to_string(), + path, + user_data_dir: resolve_profile_dir(candidate), + }); + } + } + + tracing::warn!("No Chromium-based browser found on system"); + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_known_browsers_not_empty() { + let browsers = known_browsers(); + assert!(browsers.len() >= 7, "Expected at least 7 known browsers"); + } + + #[test] + fn test_is_profile_locked_nonexistent() { + let dir = PathBuf::from("/tmp/nonexistent-browser-profile-test-opencli"); + assert!(!is_profile_locked(&dir)); + } + + #[test] + fn test_detect_default_browser_id_no_panic() { + // May return None on CI — just ensure no panic + let _ = detect_default_browser_id(); + } + + #[test] + fn test_detect_browser_no_panic() { + // May return None on CI — just ensure no panic + let _ = detect_browser(); + } + + #[test] + fn test_find_executable_nonexistent() { + let candidate = BrowserCandidate { + name: "FakeBrowser", + #[cfg(target_os = "macos")] + bundle_id: "com.fake.browser", + #[cfg(target_os = "linux")] + desktop_file: "fake.desktop", + #[cfg(target_os = "windows")] + prog_id: "FakeHTML", + paths: &["/nonexistent/path/to/fake-browser"], + which_names: &["fake-browser-that-does-not-exist"], + #[cfg(target_os = "macos")] + profile_dir: None, + #[cfg(target_os = "linux")] + profile_dir: None, + #[cfg(target_os = "windows")] + profile_dir: None, + }; + assert!(find_executable(&candidate).is_none()); + } + + #[test] + fn test_resolve_profile_dir_nonexistent() { + let candidate = BrowserCandidate { + name: "FakeBrowser", + #[cfg(target_os = "macos")] + bundle_id: "", + #[cfg(target_os = "linux")] + desktop_file: "", + #[cfg(target_os = "windows")] + prog_id: "", + paths: &[], + which_names: &[], + #[cfg(target_os = "macos")] + profile_dir: Some("NonexistentBrowser/Data"), + #[cfg(target_os = "linux")] + profile_dir: Some("nonexistent-browser"), + #[cfg(target_os = "windows")] + profile_dir: Some(r"NonexistentBrowser\Data"), + }; + assert!(resolve_profile_dir(&candidate).is_none()); + } + + #[test] + fn test_matches_default_case_insensitive() { + let candidate = BrowserCandidate { + name: "Test", + #[cfg(target_os = "macos")] + bundle_id: "com.google.Chrome", + #[cfg(target_os = "linux")] + desktop_file: "Google-Chrome.desktop", + #[cfg(target_os = "windows")] + prog_id: "ChromeHTML", + paths: &[], + which_names: &[], + #[cfg(target_os = "macos")] + profile_dir: None, + #[cfg(target_os = "linux")] + profile_dir: None, + #[cfg(target_os = "windows")] + profile_dir: None, + }; + #[cfg(target_os = "macos")] + assert!(matches_default(&candidate, "com.google.chrome")); + #[cfg(target_os = "linux")] + assert!(matches_default(&candidate, "google-chrome.desktop")); + #[cfg(target_os = "windows")] + assert!(matches_default(&candidate, "chromehtml")); + } +} diff --git a/crates/opencli-rs-browser/src/browser_launcher.rs b/crates/opencli-rs-browser/src/browser_launcher.rs new file mode 100644 index 0000000..2bdc439 --- /dev/null +++ b/crates/opencli-rs-browser/src/browser_launcher.rs @@ -0,0 +1,301 @@ +//! Launch a Chromium browser with CDP (Chrome DevTools Protocol) enabled. +//! +//! Discovers an existing CDP endpoint or launches the detected browser with +//! `--remote-debugging-port` and `--user-data-dir` so session cookies are +//! available without a Chrome extension. + +use opencli_rs_core::CliError; +use std::path::PathBuf; +use std::time::Duration; + +use crate::browser_detection::{self, BrowserInfo}; + +/// Default port range to scan/use for CDP. +const CDP_PORT_START: u16 = 9222; +const CDP_PORT_END: u16 = 9232; + +/// How long to wait for the browser to become CDP-ready. +const LAUNCH_TIMEOUT: Duration = Duration::from_secs(15); + +/// Poll interval when waiting for CDP readiness. +const POLL_INTERVAL: Duration = Duration::from_millis(250); + +/// A discovered or launched CDP endpoint. +#[derive(Debug, Clone)] +pub struct CdpEndpoint { + /// WebSocket URL for the first available page (e.g. `ws://127.0.0.1:9222/devtools/page/...`). + pub ws_url: String, + /// The CDP port. + pub port: u16, + /// Whether we launched the browser (true) or found an existing one (false). + pub launched: bool, +} + +/// Try to discover an existing CDP endpoint on the given port. +/// +/// Checks `http://127.0.0.1:{port}/json` for available pages. +pub async fn discover_existing_cdp(port: u16) -> Option { + let url = format!("http://127.0.0.1:{port}/json"); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .ok()?; + + let resp = client.get(&url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + + let pages: Vec = resp.json().await.ok()?; + // Find the first page-type target + for page in &pages { + if page.get("type").and_then(|t| t.as_str()) == Some("page") { + if let Some(ws_url) = page.get("webSocketDebuggerUrl").and_then(|u| u.as_str()) { + return Some(CdpEndpoint { + ws_url: ws_url.to_string(), + port, + launched: false, + }); + } + } + } + + // Fall back to any target with a webSocketDebuggerUrl + for page in &pages { + if let Some(ws_url) = page.get("webSocketDebuggerUrl").and_then(|u| u.as_str()) { + return Some(CdpEndpoint { + ws_url: ws_url.to_string(), + port, + launched: false, + }); + } + } + + None +} + +/// Find an available port in the CDP range. +fn find_available_port() -> Option { + for port in CDP_PORT_START..CDP_PORT_END { + if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() { + return Some(port); + } + } + None +} + +/// Fallback profile directory when no browser profile is available. +fn fallback_profile_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join(".opencli-rs") + .join("chrome-profile") +} + +/// Launch a browser with CDP enabled on the given port. +/// +/// In headless mode (default): uses `--headless=new` with a separate working +/// profile and synced cookies. Doesn't conflict with the running browser. +/// +/// In headed mode: launches a visible browser window with the user's real +/// profile. If the profile is locked (browser already running), uses a +/// fallback profile. +fn launch_browser( + info: &BrowserInfo, + port: u16, + headless: bool, +) -> Result { + // Always use a separate working profile with cookies synced from the + // user's real browser — works whether headless or headed, never conflicts + // with the user's running browser. + let working_dir = fallback_profile_dir(); + std::fs::create_dir_all(&working_dir).ok(); + if let Some(source_dir) = &info.user_data_dir { + sync_cookies(source_dir, &working_dir); + } + let profile_dir = working_dir; + + let mode = if headless { "headless" } else { "headed" }; + tracing::info!( + "Launching {} {} with CDP on port {} (profile: {})", + info.name, + mode, + port, + profile_dir.display() + ); + + let mut cmd = std::process::Command::new(&info.path); + if headless { + cmd.arg("--headless=new"); + } + cmd.arg(format!("--remote-debugging-port={port}")) + .arg(format!("--user-data-dir={}", profile_dir.display())) + .arg("--no-first-run") + .arg("--no-default-browser-check") + .arg("--disable-blink-features=AutomationControlled") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + let child = cmd + .spawn() + .map_err(|e| CliError::browser_connect(format!("Failed to launch {}: {e}", info.name)))?; + + Ok(child) +} + +/// Copy cookie and login state files from the user's real browser profile +/// to the headless working profile. This preserves authenticated sessions +/// without interfering with the running browser. +fn sync_cookies(source: &std::path::Path, dest: &std::path::Path) { + let default_src = source.join("Default"); + let default_dst = dest.join("Default"); + std::fs::create_dir_all(&default_dst).ok(); + + // Files that carry session state + let cookie_files = [ + "Cookies", + "Cookies-journal", + "Login Data", + "Login Data-journal", + "Web Data", + "Web Data-journal", + ]; + + for name in &cookie_files { + let src = default_src.join(name); + let dst = default_dst.join(name); + if src.exists() { + match std::fs::copy(&src, &dst) { + Ok(_) => tracing::debug!("Synced {name} to headless profile"), + Err(e) => tracing::debug!("Failed to sync {name}: {e}"), + } + } + } + + // Also copy the Local State file (encryption keys for cookies) + let local_state_src = source.join("Local State"); + let local_state_dst = dest.join("Local State"); + if local_state_src.exists() { + match std::fs::copy(&local_state_src, &local_state_dst) { + Ok(_) => tracing::debug!("Synced Local State to headless profile"), + Err(e) => tracing::debug!("Failed to sync Local State: {e}"), + } + } +} + +/// Wait for CDP to become ready on the given port. +async fn wait_for_cdp_ready(port: u16) -> Result { + let deadline = tokio::time::Instant::now() + LAUNCH_TIMEOUT; + + while tokio::time::Instant::now() < deadline { + if let Some(endpoint) = discover_existing_cdp(port).await { + return Ok(CdpEndpoint { + ws_url: endpoint.ws_url, + port, + launched: true, + }); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + + Err(CliError::browser_connect(format!( + "Browser did not become CDP-ready on port {port} within {}s", + LAUNCH_TIMEOUT.as_secs() + ))) +} + +/// Discover an existing CDP-enabled browser or launch one (headless by default). +/// +/// 1. Scan ports for an existing CDP endpoint +/// 2. If not found, detect browser, find available port, launch, wait for ready +/// +/// Use `headless: true` (default) for background automation — no visible window, +/// cookies synced from the user's real profile. +/// Use `headless: false` for headed mode — visible browser window for debugging +/// or when the user needs to watch automation. +pub async fn connect_or_launch() -> Result { + connect_or_launch_with(true).await +} + +/// Same as [`connect_or_launch`] but with explicit headless control. +pub async fn connect_or_launch_with(headless: bool) -> Result { + // 1. Check for existing CDP endpoint + for port in CDP_PORT_START..CDP_PORT_END { + if let Some(endpoint) = discover_existing_cdp(port).await { + tracing::info!("Found existing CDP endpoint on port {port}"); + return Ok(endpoint); + } + } + + // 2. Detect browser + let info = browser_detection::detect_browser().ok_or_else(|| { + CliError::browser_connect( + "No Chromium-based browser found. Install Chrome, Brave, Edge, or Chromium.", + ) + })?; + + // 3. Find available port and launch + let port = find_available_port().ok_or_else(|| { + CliError::browser_connect(format!( + "No available port in range {CDP_PORT_START}-{CDP_PORT_END}" + )) + })?; + + let mut child = launch_browser(&info, port, headless)?; + + // 4. Wait for CDP readiness + match wait_for_cdp_ready(port).await { + Ok(endpoint) => { + // Detach the child — browser should outlive the CLI + std::mem::forget(child); + Ok(endpoint) + } + Err(e) => { + // Clean up on failure + let _ = child.kill(); + Err(e) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_discover_existing_cdp_no_server() { + // Port 19999 should have nothing listening + let result = discover_existing_cdp(19999).await; + assert!(result.is_none()); + } + + #[test] + fn test_find_available_port() { + let port = find_available_port(); + // Should find at least one port (unless all 10 are in use) + // Don't assert Some — CI might have them occupied + if let Some(p) = port { + assert!(p >= CDP_PORT_START && p < CDP_PORT_END); + } + } + + #[test] + fn test_fallback_profile_dir() { + let dir = fallback_profile_dir(); + assert!(dir.ends_with("chrome-profile")); + assert!(dir.to_string_lossy().contains(".opencli-rs")); + } + + #[test] + fn test_cdp_endpoint_construction() { + let endpoint = CdpEndpoint { + ws_url: "ws://127.0.0.1:9222/devtools/page/abc".to_string(), + port: 9222, + launched: false, + }; + assert_eq!(endpoint.port, 9222); + assert!(!endpoint.launched); + assert!(endpoint.ws_url.starts_with("ws://")); + } +} diff --git a/crates/opencli-rs-browser/src/cdp.rs b/crates/opencli-rs-browser/src/cdp.rs index aaeeabf..5c9287f 100644 --- a/crates/opencli-rs-browser/src/cdp.rs +++ b/crates/opencli-rs-browser/src/cdp.rs @@ -15,8 +15,10 @@ use tracing::{debug, error}; use crate::dom_helpers; -type WsSink = - futures::stream::SplitSink>, Message>; +type WsSink = futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream>, + Message, +>; /// Direct Chrome DevTools Protocol page client via WebSocket. /// @@ -46,9 +48,7 @@ impl CdpPage { Ok(Message::Text(text)) => { if let Ok(json) = serde_json::from_str::(&text) { if let Some(id) = json.get("id").and_then(|v| v.as_u64()) { - if let Some(tx) = - reader_pending.write().await.remove(&id) - { + if let Some(tx) = reader_pending.write().await.remove(&id) { let _ = tx.send(json); } } else { @@ -215,9 +215,7 @@ impl IPage for CdpPage { } async fn cookies(&self, _options: Option) -> Result, CliError> { - let result = self - .send_cdp("Network.getCookies", json!({})) - .await?; + let result = self.send_cdp("Network.getCookies", json!({})).await?; let cookies_val = result.get("cookies").cloned().unwrap_or(json!([])); let cookies: Vec = serde_json::from_value(cookies_val).unwrap_or_default(); Ok(cookies) @@ -267,10 +265,7 @@ impl IPage for CdpPage { async fn tabs(&self) -> Result, CliError> { let result = self.send_cdp("Target.getTargets", json!({})).await?; - let targets = result - .get("targetInfos") - .cloned() - .unwrap_or(json!([])); + let targets = result.get("targetInfos").cloned().unwrap_or(json!([])); let mut tabs = Vec::new(); if let Some(arr) = targets.as_array() { for t in arr { diff --git a/crates/opencli-rs-browser/src/daemon.rs b/crates/opencli-rs-browser/src/daemon.rs index 6239c1a..9ed1d8f 100644 --- a/crates/opencli-rs-browser/src/daemon.rs +++ b/crates/opencli-rs-browser/src/daemon.rs @@ -172,7 +172,11 @@ async fn command_handler( // Create a oneshot channel for the result let (tx, rx) = oneshot::channel::(); - state.pending_commands.write().await.insert(cmd_id.clone(), tx); + state + .pending_commands + .write() + .await + .insert(cmd_id.clone(), tx); // Forward command to extension via WebSocket { @@ -203,7 +207,10 @@ async fn command_handler( } else { StatusCode::UNPROCESSABLE_ENTITY }; - (status, Json(serde_json::to_value(result).unwrap_or(json!({})))) + ( + status, + Json(serde_json::to_value(result).unwrap_or(json!({}))), + ) } Ok(Err(_)) => ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/opencli-rs-browser/src/daemon_client.rs b/crates/opencli-rs-browser/src/daemon_client.rs index b47da72..f60fb00 100644 --- a/crates/opencli-rs-browser/src/daemon_client.rs +++ b/crates/opencli-rs-browser/src/daemon_client.rs @@ -51,7 +51,9 @@ impl DaemonClient { if status.is_success() { let daemon_result: DaemonResult = resp.json().await.map_err(|e| { - CliError::browser_connect(format!("Failed to parse daemon response: {e}")) + CliError::browser_connect(format!( + "Failed to parse daemon response: {e}" + )) })?; if daemon_result.ok { return Ok(daemon_result.data.unwrap_or(Value::Null)); diff --git a/crates/opencli-rs-browser/src/lib.rs b/crates/opencli-rs-browser/src/lib.rs index f918e47..c8392b7 100644 --- a/crates/opencli-rs-browser/src/lib.rs +++ b/crates/opencli-rs-browser/src/lib.rs @@ -1,15 +1,17 @@ -pub mod types; +pub mod bridge; +pub mod browser_detection; +pub mod browser_launcher; +pub mod cdp; +pub mod daemon; pub mod daemon_client; -pub mod page; pub mod dom_helpers; +pub mod page; pub mod stealth; -pub mod daemon; -pub mod bridge; -pub mod cdp; +pub mod types; pub use bridge::BrowserBridge; -pub use page::DaemonPage; pub use cdp::CdpPage; pub use daemon::Daemon; pub use daemon_client::DaemonClient; +pub use page::DaemonPage; pub use types::{DaemonCommand, DaemonResult}; diff --git a/crates/opencli-rs-browser/src/page.rs b/crates/opencli-rs-browser/src/page.rs index bc318e7..e65984d 100644 --- a/crates/opencli-rs-browser/src/page.rs +++ b/crates/opencli-rs-browser/src/page.rs @@ -152,8 +152,7 @@ impl IPage for DaemonPage { async fn snapshot(&self, options: Option) -> Result { let opts = options.unwrap_or_default(); - let js = - dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); + let js = dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); self.eval_js(&js).await } @@ -244,7 +243,10 @@ pub(crate) fn base64_decode_simple(input: &str) -> Vec { } let _ = TABLE; // suppress unused warning - let bytes: Vec = input.bytes().filter(|&b| b != b'=' && b != b'\n' && b != b'\r').collect(); + let bytes: Vec = input + .bytes() + .filter(|&b| b != b'=' && b != b'\n' && b != b'\r') + .collect(); let mut out = Vec::with_capacity(bytes.len() * 3 / 4); for chunk in bytes.chunks(4) { let n = chunk.len();